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,89 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../api/client'
import type { AdminRecord, ListMeta, ListSchema } from '../api/types'
import { controllerIdFromParams } from '../app/controllerRoutes'
import { t } from '../app/i18n'
import DataTable from '../components/list/DataTable.vue'
import { activeEntry } from '../state/useNavigation'
const route = useRoute()
const path = computed(() => ({
vendor: String(route.params.vendor ?? ''),
plugin: String(route.params.plugin ?? ''),
controller: String(route.params.controller ?? ''),
}))
const schema = ref<ListSchema | null>(null)
const rows = ref<AdminRecord[]>([])
const meta = ref<ListMeta | null>(null)
const loading = ref(true)
const failed = ref(false)
const title = computed(() => {
if (schema.value?.title) {
return schema.value.title
}
const id = controllerIdFromParams(path.value)
const entry = activeEntry(path.value.vendor, path.value.plugin)
return entry?.sideMenu.find((item) => item.controller === id)?.label ?? id
})
const range = computed(() => {
const m = meta.value
if (!m || m.total === 0) {
return t('backend::lang.list.no_results')
}
const from = (m.page - 1) * m.per_page + 1
const to = Math.min(m.page * m.per_page, m.total)
return t('backend::lang.list.pagination_range', { from, to, total: m.total })
})
let generation = 0
async function load(): Promise<void> {
const current = ++generation
loading.value = true
failed.value = false
const params = { path: path.value }
const [schemaResult, listResult] = await Promise.all([
api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params }),
api.GET('/{vendor}/{plugin}/{controller}', { params }),
])
if (current !== generation) {
return
}
schema.value = schemaResult.data?.data ?? null
rows.value = listResult.data?.data ?? []
meta.value = listResult.data?.meta ?? null
failed.value = !schemaResult.data || !listResult.data
loading.value = false
}
watch(path, load, { immediate: true, deep: true })
</script>
<template>
<section class="flex flex-col gap-5">
<header>
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ title }}</h1>
</header>
<div class="overflow-hidden rounded-card border border-border bg-surface shadow-card">
<p v-if="failed && !loading" role="alert" class="px-5 py-4 text-danger">
{{ t('backend::lang.list.load_failed') }}
</p>
<DataTable
v-else
:columns="schema?.columns ?? []"
:rows="rows"
:loading="loading"
:empty-text="schema?.noRecordsMessage"
/>
<footer class="border-t border-border px-5 py-3.5 text-[13px] text-muted">
{{ loading ? t('backend::lang.list.loading') : range }}
</footer>
</div>
</section>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CircleAlert, Sun } from '@lucide/vue'
import { t } from '../app/i18n'
import { safeRedirect } from '../app/router'
import { login, me } from '../state/useAuth'
import { loadNavigation } from '../state/useNavigation'
const route = useRoute()
const router = useRouter()
const identifier = ref('')
const password = ref('')
const failed = ref(false)
const busy = ref(false)
async function submit(): Promise<void> {
if (busy.value) {
return
}
busy.value = true
failed.value = false
try {
const ok = await login(identifier.value.trim(), password.value)
if (!ok || !(await me())) {
failed.value = true
return
}
password.value = ''
await loadNavigation()
await router.replace(safeRedirect(route.query.redirect) ?? '/')
} finally {
busy.value = false
}
}
</script>
<template>
<main
class="flex min-h-screen items-center justify-center px-4"
style="background: radial-gradient(ellipse at 50% 35%, #2b3a5c 0%, #1d2740 60%)"
>
<div class="flex w-full max-w-[400px] flex-col gap-6">
<div class="flex items-center justify-center gap-3">
<span class="flex size-[34px] items-center justify-center rounded-full bg-accent-soft">
<Sun :size="20" class="text-accent" aria-hidden="true" />
</span>
<span class="text-[18px] font-bold text-white">Summer<span class="text-accent">CMS</span></span>
</div>
<form
class="flex flex-col gap-[18px] rounded-card bg-white p-8 text-[#141b2d] shadow-login"
novalidate
@submit.prevent="submit"
>
<h1 class="text-[22px] font-bold tracking-[-0.02em]">{{ t('backend::lang.auth.title') }}</h1>
<label class="flex flex-col gap-1.5">
<span class="font-semibold">{{ t('backend::lang.auth.login') }}</span>
<input
v-model="identifier"
name="login"
type="text"
autocomplete="username"
required
class="h-input rounded-control border border-[#d2d8e2] bg-white px-3.5"
/>
</label>
<label class="flex flex-col gap-1.5">
<span class="font-semibold">{{ t('backend::lang.auth.password') }}</span>
<input
v-model="password"
name="password"
type="password"
autocomplete="current-password"
required
:aria-invalid="failed ? 'true' : undefined"
:class="failed ? 'border-[#c62828]' : 'border-[#d2d8e2]'"
class="h-input rounded-control border bg-white px-3.5"
/>
</label>
<div
v-if="failed"
role="alert"
class="flex items-center gap-2.5 rounded-control bg-[#fdf0f0] px-3.5 py-3 text-[#c62828]"
>
<CircleAlert :size="18" aria-hidden="true" />
<span>{{ t('backend::lang.auth.invalid') }}</span>
</div>
<button
type="submit"
:disabled="busy"
class="h-input rounded-control bg-[#22304d] font-semibold text-white disabled:opacity-60"
>
{{ t('backend::lang.auth.submit') }}
</button>
</form>
</div>
</main>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { t } from '../app/i18n'
defineProps<{ home?: boolean }>()
</script>
<template>
<section class="flex flex-col items-center gap-2 py-20 text-center">
<h1 class="text-[17px] font-bold">
{{ home ? t('backend::lang.nav.empty') : t('backend::lang.page.not_found') }}
</h1>
</section>
</template>