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:
44
admin/src/state/useAuth.ts
Normal file
44
admin/src/state/useAuth.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
|
||||
// keeps only the profile and the access lifetime, never a token.
|
||||
import { readonly, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
|
||||
const user = ref<AdminProfile | null>(null)
|
||||
const expiresIn = ref<number | null>(null)
|
||||
|
||||
export const currentUser = readonly(user)
|
||||
|
||||
/** Logs in over cookie transport. Returns false on invalid credentials. */
|
||||
export async function login(identifier: string, password: string): Promise<boolean> {
|
||||
const { data, response } = await api.POST('/auth/login', {
|
||||
body: { login: identifier, password },
|
||||
})
|
||||
if (!response.ok || !data) {
|
||||
return false
|
||||
}
|
||||
expiresIn.value = typeof data.data.expires_in === 'number' ? data.data.expires_in : null
|
||||
return true
|
||||
}
|
||||
|
||||
/** Loads the signed-in admin; null when the session is missing or expired. */
|
||||
export async function me(): Promise<AdminProfile | null> {
|
||||
const { data, response } = await api.GET('/auth/me')
|
||||
user.value = response.ok && data ? data.data : null
|
||||
return user.value
|
||||
}
|
||||
|
||||
export function clearUser(): void {
|
||||
user.value = null
|
||||
expiresIn.value = null
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return {
|
||||
user: currentUser,
|
||||
expiresIn: readonly(expiresIn),
|
||||
login,
|
||||
me,
|
||||
clearUser,
|
||||
}
|
||||
}
|
||||
65
admin/src/state/useNavigation.ts
Normal file
65
admin/src/state/useNavigation.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Server-filtered navigation (D-10). The server removes items the admin may
|
||||
// not open; the rail also omits a plugin whose side menu ends up empty.
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import type { NavigationEntry } from '../api/types'
|
||||
import { controllerPath, pluginKey } from '../app/controllerRoutes'
|
||||
|
||||
const entries = ref<NavigationEntry[]>([])
|
||||
|
||||
export const navigation = readonly(entries)
|
||||
|
||||
export async function loadNavigation(): Promise<NavigationEntry[]> {
|
||||
const { data, response } = await api.GET('/navigation')
|
||||
entries.value = response.ok && data ? data.data : []
|
||||
return entries.value
|
||||
}
|
||||
|
||||
export function setNavigation(next: NavigationEntry[]): void {
|
||||
entries.value = next
|
||||
}
|
||||
|
||||
/** Rail entries: plugins with at least one permitted side-menu item, by order. */
|
||||
export const railEntries = computed<NavigationEntry[]>(() =>
|
||||
entries.value
|
||||
.filter((entry) => entry.sideMenu.length > 0)
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.sort((a, b) => a.entry.order - b.entry.order || a.index - b.index)
|
||||
.map(({ entry }) => entry),
|
||||
)
|
||||
|
||||
/** Path of the plugin's first permitted side-menu controller. */
|
||||
export function firstControllerPath(entry: NavigationEntry): string | null {
|
||||
for (const item of entry.sideMenu) {
|
||||
const path = controllerPath(item.controller)
|
||||
if (path) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return controllerPath(entry.controller)
|
||||
}
|
||||
|
||||
/** The rail entry owning a route's vendor and plugin segments. */
|
||||
export function activeEntry(vendor: string, plugin: string): NavigationEntry | null {
|
||||
const key = `${vendor}.${plugin}`
|
||||
return (
|
||||
railEntries.value.find(
|
||||
(entry) => pluginKey(entry.controller) === key || entry.sideMenu.some((item) => pluginKey(item.controller) === key),
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/** Where "/" lands: the first plugin's first controller. */
|
||||
export function homePath(): string | null {
|
||||
for (const entry of railEntries.value) {
|
||||
const path = firstControllerPath(entry)
|
||||
if (path) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function useNavigation() {
|
||||
return { navigation, railEntries, loadNavigation, firstControllerPath, activeEntry, homePath }
|
||||
}
|
||||
Reference in New Issue
Block a user