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:
42
admin/src/app/controllerRoutes.ts
Normal file
42
admin/src/app/controllerRoutes.ts
Normal 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
42
admin/src/app/i18n.ts
Normal 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
111
admin/src/app/icons.ts
Normal 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
63
admin/src/app/router.ts
Normal 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
23
admin/src/app/runtime.ts
Normal 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
|
||||
Reference in New Issue
Block a user