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

View File

@@ -0,0 +1,42 @@
// Controller IDs map one to one onto SPA paths (D-10):
// vendor.plugin.controller <-> /vendor/plugin/controller.
export interface ControllerParams {
vendor: string
plugin: string
controller: string
}
const SEGMENT = /^[A-Za-z0-9_-]+$/
export function parseControllerId(id: string): ControllerParams | null {
const parts = id.split('.')
if (parts.length !== 3 || !parts.every((part) => SEGMENT.test(part))) {
return null
}
const [vendor, plugin, controller] = parts as [string, string, string]
return { vendor, plugin, controller }
}
export function controllerPath(id: string): string | null {
const params = parseControllerId(id)
return params ? `/${params.vendor}/${params.plugin}/${params.controller}` : null
}
export function controllerIdFromParams(params: ControllerParams): string {
return `${params.vendor}.${params.plugin}.${params.controller}`
}
export function controllerIdFromPath(path: string): string | null {
const parts = path.replace(/^\/+|\/+$/g, '').split('/')
if (parts.length < 3) {
return null
}
const id = parts.slice(0, 3).join('.')
return parseControllerId(id) ? id : null
}
/** vendor.plugin prefix of a controller ID, used to find the owning plugin. */
export function pluginKey(id: string): string | null {
const params = parseControllerId(id)
return params ? `${params.vendor}.${params.plugin}` : null
}

42
admin/src/app/i18n.ts Normal file
View File

@@ -0,0 +1,42 @@
// UI strings (D-20). The resolved backend::lang bundle is loaded from the
// server in a later plan; until a key is loaded, t() returns the key itself,
// mirroring phrasebook's missing-key fallback. Placeholders use phrasebook's
// :name syntax with :Name and :NAME casing variants (D-24).
import { ref } from 'vue'
type Forms = Record<string, string>
const bundle = ref<Record<string, Forms>>({})
export function setBundle(next: Record<string, Forms>): void {
bundle.value = next
}
export function interpolate(text: string, params: Record<string, string | number> = {}): string {
const replacements: Array<[string, string]> = []
for (const [rawName, raw] of Object.entries(params)) {
const name = rawName.replace(/^:/, '')
if (name === '') {
continue
}
const value = String(raw)
replacements.push([`:${name.charAt(0).toUpperCase()}${name.slice(1)}`, value.charAt(0).toUpperCase() + value.slice(1)])
replacements.push([`:${name.toUpperCase()}`, value.toUpperCase()])
replacements.push([`:${name}`, value])
}
replacements.sort((a, b) => b[0].length - a[0].length)
let out = text
for (const [placeholder, value] of replacements) {
out = out.split(placeholder).join(value)
}
return out
}
export function t(key: string, params: Record<string, string | number> = {}): string {
const forms = bundle.value[key]
const text = forms?.other
if (text === undefined) {
return key
}
return interpolate(text, params)
}

111
admin/src/app/icons.ts Normal file
View File

@@ -0,0 +1,111 @@
// Navigation icons (D-11). The registry stores lucide names; ported Winter
// plugins may still send icon-* names, which map onto lucide equivalents.
// Named imports only: a namespace import would bundle every lucide icon.
import type { Component } from 'vue'
import {
Archive,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpRight,
CassetteTape,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Circle,
CircleAlert,
Disc,
Disc3,
Image,
Library,
List,
LogOut,
MicVocal,
Minus,
Palette,
PanelLeftClose,
PanelLeftOpen,
Plus,
Puzzle,
Search,
SearchX,
Settings,
ShieldCheck,
Square,
Sun,
Tags,
Trash2,
User,
UserMinus,
UserPlus,
Users,
UsersRound,
X,
} from '@lucide/vue'
export const icons: Readonly<Record<string, Component>> = {
archive: Archive,
'arrow-down': ArrowDown,
'arrow-left': ArrowLeft,
'arrow-right': ArrowRight,
'arrow-up': ArrowUp,
'arrow-up-right': ArrowUpRight,
'cassette-tape': CassetteTape,
check: Check,
'chevron-down': ChevronDown,
'chevron-left': ChevronLeft,
'chevron-right': ChevronRight,
circle: Circle,
'circle-alert': CircleAlert,
disc: Disc,
'disc-3': Disc3,
image: Image,
library: Library,
list: List,
'log-out': LogOut,
'mic-vocal': MicVocal,
minus: Minus,
palette: Palette,
'panel-left-close': PanelLeftClose,
'panel-left-open': PanelLeftOpen,
plus: Plus,
puzzle: Puzzle,
search: Search,
'search-x': SearchX,
settings: Settings,
'shield-check': ShieldCheck,
sun: Sun,
tags: Tags,
'trash-2': Trash2,
user: User,
'user-minus': UserMinus,
'user-plus': UserPlus,
users: Users,
'users-round': UsersRound,
x: X,
}
/** Winter backend icon classes mapped to lucide names. */
export const winterIcons: Readonly<Record<string, string>> = {
'icon-archive': 'archive',
'icon-circle': 'circle',
'icon-list-ul': 'list',
'icon-tags': 'tags',
'icon-user': 'user',
'icon-search': 'search',
'icon-cog': 'settings',
'icon-users': 'users',
}
/** Neutral icon for names neither lucide map knows. */
export const fallbackIcon: Component = Square
export function iconFor(name: string | null | undefined): Component {
if (!name) {
return fallbackIcon
}
const key = name.trim()
return icons[key] ?? icons[winterIcons[key] ?? ''] ?? fallbackIcon
}

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
}

23
admin/src/app/runtime.ts Normal file
View File

@@ -0,0 +1,23 @@
// Runtime configuration read once from the served index.html. The Go server
// (boardwalk) writes the configured backend.uri into the summer-admin-base
// meta; the SPA derives its router base and API base from it (D-02, D-03).
export const DEFAULT_BASE = '/backend'
const TOKEN = '__SUMMER_ADMIN_BASE__'
export function readBase(doc: Document = document): string {
const content = doc.querySelector<HTMLMetaElement>('meta[name="summer-admin-base"]')?.content.trim() ?? ''
if (content === '' || content === TOKEN || !content.startsWith('/')) {
return DEFAULT_BASE
}
const trimmed = content.replace(/\/+$/, '')
return trimmed === '' ? DEFAULT_BASE : trimmed
}
const base = readBase()
export const runtime = {
/** Admin mount path, for example /backend. */
base,
/** Admin API root, the mount path plus /api/v1. */
api: `${base}/api/v1`,
} as const