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:
89
admin/src/views/ListView.vue
Normal file
89
admin/src/views/ListView.vue
Normal 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>
|
||||
99
admin/src/views/LoginView.vue
Normal file
99
admin/src/views/LoginView.vue
Normal 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>
|
||||
13
admin/src/views/NotFoundView.vue
Normal file
13
admin/src/views/NotFoundView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user