Files
summercms/admin/src/app/router.ts
Jakub Zych 143eca5a1f feat(cabana): add the My account page with subtitle and password change
- lagoon: 202610090001_backend_users_metadata adds WinterCMS's nullable
  jsonable backend_users.metadata column (TEXT); rollback drops only it
- cabana.BackendUser.Metadata and Subtitle read metadata.subtitle leniently
  (NULL, blank, null, [], invalid JSON and non-strings read as empty)
- GET /auth/me returns subtitle; PUT /account edits the signed-in admin's
  own names, email and subtitle, and the password with current_password;
  any other key is a 422 and nothing is written
- email: required, valid, 6-255 chars, unique against other admins' email
  and login ignoring case (soft-deleted included); a unique-index race is 422
- a password change sets tokens_valid_after, revokes the presented token and
  gives a cookie session a fresh cookie
- swag annotation, regenerated admin.json and schema.d.ts; route inventories,
  CSRF walk (27) and OpenAPI conformance learn the route
- SPA: My account item in the user menu opens /account (AccountView), which
  saves through PUT /account and updates the top bar via setCurrentUser
- backend::lang.myaccount strings (en, pl), rebuilt boardwalk dist
- cabana and lagoon READMEs, users-and-permissions and admin-spa docs
2026-10-09 17:38:04 +02:00

108 lines
4.1 KiB
TypeScript

import { createRouter, createWebHistory, type RouteLocationNormalized, type RouterHistory } from 'vue-router'
import { controllerIdFromParams } from './controllerRoutes'
import { activateStyles } from './pluginAssets'
import { runtime } from './runtime'
import { currentUser } from '../state/useAuth'
import { homePath } from '../state/useNavigation'
import LoginView from '../views/LoginView.vue'
import ListView from '../views/ListView.vue'
import FormView from '../views/FormView.vue'
import PreviewView from '../views/PreviewView.vue'
import SettingsFormView from '../views/SettingsFormView.vue'
import SettingsIndexView from '../views/SettingsIndexView.vue'
import AccountView from '../views/AccountView.vue'
import NotFoundView from '../views/NotFoundView.vue'
declare module 'vue-router' {
interface RouteMeta {
/** Reachable without a session. */
public?: boolean
/** Rendered inside the navigation shell. */
shell?: boolean
}
}
/**
* Accepts a post-login redirect only when it is an in-app path: it must start
* with exactly one slash, which rejects absolute and protocol-relative URLs.
*/
export function safeRedirect(value: unknown): string | null {
if (typeof value !== 'string' || value.length === 0) {
return null
}
if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
return null
}
return value
}
const CONTROLLER_ROUTES = new Set(['list', 'create', 'record', 'preview'])
/**
* The controller a route shows, or '' for every other screen (settings,
* login, not found). Plugin stylesheets follow it (D-16, T-10.1-16).
*/
export function routeControllerId(route: RouteLocationNormalized): string {
if (typeof route.name !== 'string' || !CONTROLLER_ROUTES.has(route.name)) {
return ''
}
const { vendor, plugin, controller } = route.params
if (typeof vendor !== 'string' || typeof plugin !== 'string' || typeof controller !== 'string') {
return ''
}
return controllerIdFromParams({ vendor, plugin, controller })
}
export function createAdminRouter(history: RouterHistory = createWebHistory(runtime.base)) {
const router = createRouter({
history,
routes: [
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
{
path: '/',
name: 'home',
component: NotFoundView,
props: { home: true },
meta: { shell: true },
beforeEnter: () => homePath() ?? true,
},
{ path: '/settings', name: 'settings', component: SettingsIndexView, meta: { shell: true } },
{ path: '/settings/:code', name: 'settings-form', component: SettingsFormView, meta: { shell: true } },
// My account: the signed-in admin's own profile and password.
{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
// The read-only record screen of a form with a preview block (D-11).
{
path: '/:vendor/:plugin/:controller/:id(\\d+)/preview',
name: 'preview',
component: PreviewView,
meta: { shell: true },
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
],
})
router.beforeEach((to) => {
const signedIn = currentUser.value !== null
if (to.meta.public) {
if (signedIn && to.name === 'login') {
return safeRedirect(to.query.redirect) ?? '/'
}
return true
}
if (!signedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
return true
})
// Plugin CSS is global: enable only the links of the controller on screen,
// and none on any other screen (D-16, T-10.1-16).
router.afterEach((to, _from, failure) => {
if (!failure) {
activateStyles(routeControllerId(to))
}
})
return router
}