- 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
108 lines
4.1 KiB
TypeScript
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
|
|
}
|