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,85 @@
<script setup lang="ts">
import type { AdminRecord, ListColumn } from '../../api/types'
import { t } from '../../app/i18n'
// Read-only, schema-driven table: columns come from the list schema and each
// record is read through its column keys (D-16). Selection, sorting and
// row links arrive with the list screens.
defineProps<{
columns: ListColumn[]
rows: AdminRecord[]
loading?: boolean
emptyText?: string
}>()
function cellText(row: AdminRecord, column: ListColumn): string {
return formatValue(row[column.key])
}
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === '') {
return '—'
}
if (Array.isArray(value)) {
const parts = value.map(formatValue).filter((part) => part !== '—')
return parts.length === 0 ? '—' : parts.join(', ')
}
if (typeof value === 'boolean') {
return value ? t('backend::lang.list.column_switch_true') : t('backend::lang.list.column_switch_false')
}
if (typeof value === 'object') {
return '—'
}
return String(value)
}
function rowKey(row: AdminRecord, index: number): string {
const id = row.id
return typeof id === 'number' || typeof id === 'string' ? String(id) : `row-${index}`
}
</script>
<template>
<div class="overflow-x-auto">
<table class="w-full min-w-[640px] border-collapse text-left">
<thead>
<tr class="h-row-head border-y border-border bg-subtle">
<th
v-for="column in columns"
:key="column.key"
scope="col"
class="px-3.5 text-[12px] font-semibold text-muted first:pl-5 last:pr-5"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody :aria-busy="loading ? 'true' : undefined">
<template v-if="loading">
<tr v-for="n in 8" :key="`skeleton-${n}`" class="h-row border-b border-border">
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
<span class="block h-3 w-3/5 rounded-[6px] bg-skel" />
</td>
</tr>
</template>
<tr v-else-if="rows.length === 0">
<td :colspan="Math.max(columns.length, 1)" class="px-5 py-20 text-center text-muted">
{{ emptyText ?? t('backend::lang.list.no_records') }}
</td>
</tr>
<template v-else>
<tr v-for="(row, index) in rows" :key="rowKey(row, index)" class="h-row border-b border-border">
<td
v-for="(column, columnIndex) in columns"
:key="column.key"
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
class="px-3.5 first:pl-5 last:pr-5"
>
{{ cellText(row, column) }}
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { ChevronRight } from '@lucide/vue'
import PluginRail from './PluginRail.vue'
import SectionPanel from './SectionPanel.vue'
import { t } from '../../app/i18n'
import { controllerIdFromPath } from '../../app/controllerRoutes'
import { currentUser } from '../../state/useAuth'
import { activeEntry } from '../../state/useNavigation'
const route = useRoute()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
const section = computed(() => {
const id = controllerIdFromPath(route.path)
return active.value?.sideMenu.find((item) => item.controller === id) ?? null
})
const displayName = computed(() => {
const user = currentUser.value
if (!user) {
return ''
}
const name = `${user.first_name} ${user.last_name}`.trim()
return name === '' ? user.login : name
})
const initials = computed(() =>
displayName.value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join(''),
)
</script>
<template>
<div class="flex min-h-screen bg-bg text-text">
<PluginRail />
<SectionPanel v-if="active" :entry="active" />
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-header shrink-0 items-center justify-between border-b border-border bg-surface pr-6 pl-8">
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="flex items-center gap-2">
<template v-if="active">
<span :class="section ? 'text-muted' : 'font-semibold'">{{ active.label }}</span>
<template v-if="section">
<ChevronRight :size="14" class="text-muted" aria-hidden="true" />
<span class="font-semibold" aria-current="page">{{ section.label }}</span>
</template>
</template>
</nav>
<div v-if="currentUser" class="flex h-input items-center gap-2.5 rounded-inner px-2">
<span
class="flex size-[34px] items-center justify-center rounded-full bg-accent text-[13px] font-bold text-on-accent"
aria-hidden="true"
>{{ initials }}</span
>
<span class="flex flex-col leading-tight">
<span class="font-semibold">{{ displayName }}</span>
<span v-if="currentUser.role" class="text-[12px] text-muted">{{ currentUser.role.name }}</span>
</span>
</div>
</header>
<main class="min-w-0 flex-1 px-8 py-7">
<slot />
</main>
</div>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { Sun } from '@lucide/vue'
import { t } from '../../app/i18n'
import { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
const route = useRoute()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
const items = computed(() =>
railEntries.value.map((entry) => ({
entry,
to: firstControllerPath(entry) ?? '/',
icon: iconFor(entry.icon),
current: active.value?.code === entry.code,
})),
)
</script>
<template>
<nav
:aria-label="t('backend::lang.nav.plugins')"
class="flex w-rail shrink-0 flex-col items-center gap-1.5 border-r border-side-border bg-side pt-3.5 pb-3"
>
<span class="mb-3.5 flex size-10 items-center justify-center rounded-inner bg-accent-soft" title="SummerCMS">
<Sun :size="22" class="text-accent" aria-hidden="true" />
</span>
<RouterLink
v-for="item in items"
:key="item.entry.code"
:to="item.to"
:aria-current="item.current ? 'page' : undefined"
:class="item.current ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline"
>
<component :is="item.icon" :size="20" aria-hidden="true" />
<span>{{ item.entry.label }}</span>
</RouterLink>
</nav>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import type { NavigationEntry } from '../../api/types'
import { controllerIdFromPath, controllerPath } from '../../app/controllerRoutes'
import { iconFor } from '../../app/icons'
import { t } from '../../app/i18n'
const props = defineProps<{ entry: NavigationEntry }>()
const route = useRoute()
const currentController = computed(() => controllerIdFromPath(route.path))
const items = computed(() =>
props.entry.sideMenu.flatMap((item) => {
const to = controllerPath(item.controller)
if (!to) {
return []
}
return [{ item, to, icon: iconFor(item.icon), current: currentController.value === item.controller }]
}),
)
</script>
<template>
<aside
:aria-label="t('backend::lang.nav.sections')"
class="flex w-panel shrink-0 flex-col gap-0.5 border-r border-border bg-surface px-3 py-3.5"
>
<div class="mb-3.5 flex h-10 items-center px-2">
<span class="text-[16px] font-bold">{{ entry.label }}</span>
</div>
<RouterLink
v-for="link in items"
:key="link.item.code"
:to="link.to"
:aria-current="link.current ? 'page' : undefined"
:class="link.current ? 'bg-sel font-semibold text-text' : 'font-medium text-muted hover:bg-hover hover:text-text'"
class="flex h-nav items-center gap-3 rounded-control px-3 no-underline"
>
<component :is="link.icon" :size="18" aria-hidden="true" />
<span>{{ link.item.label }}</span>
</RouterLink>
</aside>
</template>