feat(10-01): serve the embedded admin SPA at backend.uri with cookie login

- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
  and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
  cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
  and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
  with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
  through the openapi-fetch client typed by the generated schema
This commit is contained in:
Jakub Zych
2026-09-27 15:21:48 +02:00
parent 8c3e131111
commit 5f9353841b
79 changed files with 10745 additions and 147 deletions

63
admin/src/app/router.ts Normal file
View File

@@ -0,0 +1,63 @@
import { createRouter, createWebHistory, type RouterHistory } from 'vue-router'
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 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
}
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: '/:vendor/:plugin/:controller', name: 'list', component: ListView, 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
})
return router
}