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:
1
admin/env.d.ts
vendored
Normal file
1
admin/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
14
admin/index.html
Normal file
14
admin/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<title>SummerCMS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2238
admin/openapi/admin.json
Normal file
2238
admin/openapi/admin.json
Normal file
File diff suppressed because it is too large
Load Diff
4403
admin/package-lock.json
generated
Normal file
4403
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
admin/package.json
Normal file
36
admin/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "summercms-admin",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.6"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"gen:api": "openapi-typescript openapi/admin.json -o src/api/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/dm-mono": "5.3.0",
|
||||
"@fontsource/dm-sans": "5.3.0",
|
||||
"@lucide/vue": "1.17.0",
|
||||
"openapi-fetch": "0.17.0",
|
||||
"reka-ui": "2.9.10",
|
||||
"vue": "3.5.35",
|
||||
"vue-router": "5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.3.0",
|
||||
"@vitejs/plugin-vue": "6.0.8",
|
||||
"@vue/test-utils": "2.4.11",
|
||||
"happy-dom": "20.11.6",
|
||||
"openapi-typescript": "7.13.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.5",
|
||||
"vitest": "3.2.7",
|
||||
"vue-tsc": "3.3.11"
|
||||
}
|
||||
}
|
||||
15
admin/src/App.vue
Normal file
15
admin/src/App.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import AppShell from './components/shell/AppShell.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const inShell = computed(() => route.meta.shell === true)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell v-if="inShell">
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
<RouterView v-else />
|
||||
</template>
|
||||
44
admin/src/api/client.ts
Normal file
44
admin/src/api/client.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// The only HTTP client of the SPA: openapi-fetch typed by the generated
|
||||
// paths (D-15). Every request carries X-Requested-With (the CSRF header the
|
||||
// admin API requires on state-changing cookie requests, D-19) and same-origin
|
||||
// credentials; the JWT lives in an HttpOnly cookie the SPA never reads.
|
||||
import createClient, { type Middleware } from 'openapi-fetch'
|
||||
import type { paths } from './schema'
|
||||
import { runtime } from '../app/runtime'
|
||||
|
||||
export const REQUESTED_WITH = 'XMLHttpRequest'
|
||||
|
||||
type UnauthorizedHandler = () => void
|
||||
|
||||
let unauthorizedHandler: UnauthorizedHandler | null = null
|
||||
|
||||
/** Registers what happens when an API call other than login returns 401. */
|
||||
export function onUnauthorized(handler: UnauthorizedHandler | null): void {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
function isLoginRequest(request: Request): boolean {
|
||||
return new URL(request.url, 'http://local').pathname.endsWith('/auth/login')
|
||||
}
|
||||
|
||||
export const transport: Middleware = {
|
||||
onRequest({ request }) {
|
||||
request.headers.set('X-Requested-With', REQUESTED_WITH)
|
||||
return request
|
||||
},
|
||||
onResponse({ request, response }) {
|
||||
if (response.status === 401 && !isLoginRequest(request)) {
|
||||
unauthorizedHandler?.()
|
||||
}
|
||||
return response
|
||||
},
|
||||
}
|
||||
|
||||
export const api = createClient<paths>({
|
||||
baseUrl: runtime.api,
|
||||
credentials: 'same-origin',
|
||||
// Resolve fetch per call so tests can replace globalThis.fetch.
|
||||
fetch: (request: Request) => globalThis.fetch(request),
|
||||
})
|
||||
|
||||
api.use(transport)
|
||||
1532
admin/src/api/schema.d.ts
vendored
Normal file
1532
admin/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
16
admin/src/api/types.ts
Normal file
16
admin/src/api/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Aliases onto the generated OpenAPI schema (D-15, D-16). No API shape is
|
||||
// written by hand: every type here points at components['schemas'].
|
||||
import type { components } from './schema'
|
||||
|
||||
type Schemas = components['schemas']
|
||||
|
||||
export type AdminLoginData = Schemas['cabana.AdminLoginData']
|
||||
export type AdminLoginRequest = Schemas['cabana.AdminLoginRequest']
|
||||
export type AdminProfile = Schemas['cabana.AdminProfile']
|
||||
export type NavigationEntry = Schemas['cabana.NavigationEntry']
|
||||
export type ListSchema = Schemas['cabana.ListSchema']
|
||||
export type ListColumn = Schemas['cabana.ListColumn']
|
||||
export type ListMeta = Schemas['cabana.ListMeta']
|
||||
export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope']
|
||||
/** One record: a string-keyed map read through its list or form schema. */
|
||||
export type AdminRecord = Schemas['cabana.ListEnvelope-array_cabana_AdminRecord']['data'][number]
|
||||
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
|
||||
85
admin/src/components/list/DataTable.vue
Normal file
85
admin/src/components/list/DataTable.vue
Normal 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>
|
||||
72
admin/src/components/shell/AppShell.vue
Normal file
72
admin/src/components/shell/AppShell.vue
Normal 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>
|
||||
43
admin/src/components/shell/PluginRail.vue
Normal file
43
admin/src/components/shell/PluginRail.vue
Normal 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>
|
||||
45
admin/src/components/shell/SectionPanel.vue
Normal file
45
admin/src/components/shell/SectionPanel.vue
Normal 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>
|
||||
30
admin/src/main.ts
Normal file
30
admin/src/main.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { clearUser, me } from './state/useAuth'
|
||||
import { loadNavigation } from './state/useNavigation'
|
||||
import './styles/main.css'
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
// A 401 here only means "not signed in"; the router guard sends the
|
||||
// visitor to the login route with the requested path as redirect.
|
||||
const user = await me().catch(() => null)
|
||||
if (user) {
|
||||
await loadNavigation().catch(() => [])
|
||||
}
|
||||
const router = createAdminRouter()
|
||||
onUnauthorized(() => {
|
||||
clearUser()
|
||||
const current = router.currentRoute.value
|
||||
if (current.name !== 'login') {
|
||||
void router.push({ name: 'login', query: { redirect: current.fullPath } })
|
||||
}
|
||||
})
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
await router.isReady()
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
void boot()
|
||||
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 }
|
||||
}
|
||||
160
admin/src/styles/main.css
Normal file
160
admin/src/styles/main.css
Normal file
@@ -0,0 +1,160 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Self-hosted fonts (D-07). Whole-weight files carry latin and latin-ext with
|
||||
unicode-range, so Polish diacritics and basic Latin both resolve. */
|
||||
@import "@fontsource/dm-sans/400.css";
|
||||
@import "@fontsource/dm-sans/500.css";
|
||||
@import "@fontsource/dm-sans/600.css";
|
||||
@import "@fontsource/dm-sans/700.css";
|
||||
@import "@fontsource/dm-mono/400.css";
|
||||
@import "@fontsource/dm-mono/500.css";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* Design tokens from design/README.md (Direction C v2). Utilities read CSS
|
||||
variables so light and dark mode swap values without new classes. */
|
||||
@theme {
|
||||
--font-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "DM Mono", ui-monospace, monospace;
|
||||
|
||||
--color-bg: var(--c-bg);
|
||||
--color-surface: var(--c-surface);
|
||||
--color-subtle: var(--c-subtle);
|
||||
--color-border: var(--c-border);
|
||||
--color-border-strong: var(--c-border-strong);
|
||||
--color-text: var(--c-text);
|
||||
--color-muted: var(--c-muted);
|
||||
--color-placeholder: var(--c-placeholder);
|
||||
--color-primary: var(--c-primary);
|
||||
--color-on-primary: var(--c-on-primary);
|
||||
--color-danger: var(--c-danger);
|
||||
--color-danger-soft: var(--c-danger-soft);
|
||||
--color-ok-bg: var(--c-ok-bg);
|
||||
--color-ok-text: var(--c-ok-text);
|
||||
--color-ring: var(--c-ring);
|
||||
--color-hover: var(--c-hover);
|
||||
--color-sel: var(--c-sel);
|
||||
--color-skel: var(--c-skel);
|
||||
--color-overlay: var(--c-overlay);
|
||||
--color-side: var(--c-side);
|
||||
--color-side-border: var(--c-side-border);
|
||||
--color-accent: #fcd34d;
|
||||
--color-on-accent: #1b2540;
|
||||
--color-accent-soft: rgba(252, 211, 77, 0.14);
|
||||
--color-side-text: #c3cbda;
|
||||
--color-side-label: #9aa6bd;
|
||||
--color-side-hover: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--radius-control: 10px;
|
||||
--radius-card: 16px;
|
||||
--radius-modal: 20px;
|
||||
--radius-inner: 12px;
|
||||
--radius-tab: 9px;
|
||||
--radius-checkbox: 5px;
|
||||
--radius-pager: 8px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--spacing-button: 42px;
|
||||
--spacing-input: 44px;
|
||||
--spacing-header: 64px;
|
||||
--spacing-nav: 40px;
|
||||
--spacing-row: 54px;
|
||||
--spacing-row-head: 44px;
|
||||
--spacing-pager: 34px;
|
||||
--spacing-rail: 80px;
|
||||
--spacing-panel: 224px;
|
||||
|
||||
--shadow-card: var(--c-shadow-card);
|
||||
--shadow-login: 0 24px 60px rgba(0, 0, 0, 0.35);
|
||||
--shadow-pop: 0 16px 40px rgba(20, 27, 45, 0.18);
|
||||
--shadow-menu: 0 16px 40px rgba(20, 27, 45, 0.16);
|
||||
--shadow-toast: 0 16px 40px rgba(0, 0, 0, 0.25);
|
||||
--shadow-tab: 0 1px 3px rgba(20, 27, 45, 0.12);
|
||||
}
|
||||
|
||||
:root {
|
||||
--c-bg: #f4f6f9;
|
||||
--c-surface: #ffffff;
|
||||
--c-subtle: #f3f5f8;
|
||||
--c-border: #e6e9ef;
|
||||
--c-border-strong: #d2d8e2;
|
||||
--c-text: #141b2d;
|
||||
--c-muted: #566175;
|
||||
--c-placeholder: #6b7588;
|
||||
--c-primary: #22304d;
|
||||
--c-on-primary: #ffffff;
|
||||
--c-danger: #c62828;
|
||||
--c-danger-soft: #fdf0f0;
|
||||
--c-ok-bg: #e3f4e8;
|
||||
--c-ok-text: #1c6b35;
|
||||
--c-ring: rgba(252, 196, 40, 0.55);
|
||||
--c-hover: #f1f3f7;
|
||||
--c-sel: #fdf3cf;
|
||||
--c-skel: #eceff4;
|
||||
--c-overlay: rgba(20, 27, 45, 0.5);
|
||||
--c-side: #1d2740;
|
||||
--c-side-border: transparent;
|
||||
--c-shadow-card: 0 1px 2px rgba(20, 27, 45, 0.04), 0 4px 16px rgba(20, 27, 45, 0.05);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--c-bg: #111726;
|
||||
--c-surface: #182033;
|
||||
--c-subtle: #1f283d;
|
||||
--c-border: #29334b;
|
||||
--c-border-strong: #3a4661;
|
||||
--c-text: #eef1f6;
|
||||
--c-muted: #a9b3c6;
|
||||
--c-placeholder: #8a95ab;
|
||||
--c-primary: #fcd34d;
|
||||
--c-on-primary: #1b2540;
|
||||
--c-danger: #f58a8a;
|
||||
--c-danger-soft: #3a1d24;
|
||||
--c-ok-bg: #173826;
|
||||
--c-ok-text: #8fdfa8;
|
||||
--c-ring: rgba(252, 211, 77, 0.45);
|
||||
--c-hover: #212b42;
|
||||
--c-sel: #3a3622;
|
||||
--c-skel: #263049;
|
||||
--c-overlay: rgba(5, 8, 16, 0.7);
|
||||
--c-side: #0d1320;
|
||||
--c-side-border: #222b40;
|
||||
--c-shadow-card: none;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--c-text);
|
||||
background: var(--c-bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Every interactive element shows the 3px ring; never remove it. */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid var(--c-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px var(--c-ring);
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--c-placeholder);
|
||||
}
|
||||
}
|
||||
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>
|
||||
55
admin/tests/fixtures/navigation.json
vendored
Normal file
55
admin/tests/fixtures/navigation.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"code": "demo",
|
||||
"label": "Demo",
|
||||
"icon": "disc-3",
|
||||
"order": 100,
|
||||
"controller": "acme.demo.widgets",
|
||||
"sideMenu": [
|
||||
{
|
||||
"code": "widgets",
|
||||
"label": "Widgets",
|
||||
"icon": "tags",
|
||||
"order": 0,
|
||||
"controller": "acme.demo.widgets",
|
||||
"sideMenu": []
|
||||
},
|
||||
{
|
||||
"code": "gadgets",
|
||||
"label": "Gadgets",
|
||||
"icon": "icon-archive",
|
||||
"order": 0,
|
||||
"controller": "acme.demo.gadgets",
|
||||
"sideMenu": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "tools",
|
||||
"label": "Tools",
|
||||
"icon": "unknown-icon-name",
|
||||
"order": 300,
|
||||
"controller": "acme.tools.hammers",
|
||||
"sideMenu": [
|
||||
{
|
||||
"code": "hammers",
|
||||
"label": "Hammers",
|
||||
"icon": "puzzle",
|
||||
"order": 0,
|
||||
"controller": "acme.tools.hammers",
|
||||
"sideMenu": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"code": "hidden",
|
||||
"label": "Hidden",
|
||||
"icon": "users",
|
||||
"order": 200,
|
||||
"controller": "acme.hidden.items",
|
||||
"sideMenu": []
|
||||
}
|
||||
],
|
||||
"meta": { "locale": "en" }
|
||||
}
|
||||
22
admin/tests/fixtures/widgets.list-schema.json
vendored
Normal file
22
admin/tests/fixtures/widgets.list-schema.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"data": {
|
||||
"title": "Widgets",
|
||||
"recordsPerPage": 20,
|
||||
"perPageOptions": [],
|
||||
"showSearch": true,
|
||||
"showSetup": false,
|
||||
"showCheckboxes": true,
|
||||
"showSorting": true,
|
||||
"searchTerm": "",
|
||||
"toolbarButtons": ["create"],
|
||||
"columns": [
|
||||
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
|
||||
{ "key": "code", "label": "Code", "searchable": true, "sortable": true },
|
||||
{ "key": "tags", "label": "Tags", "searchable": false, "sortable": false, "type": "relation", "relation": "tags", "select": "name" }
|
||||
],
|
||||
"filters": [],
|
||||
"rowActions": [],
|
||||
"bulkActions": []
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
}
|
||||
7
admin/tests/fixtures/widgets.list.json
vendored
Normal file
7
admin/tests/fixtures/widgets.list.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, "name": "Blue widget", "code": "W-01", "tags": ["small", "round"] },
|
||||
{ "id": 2, "name": "Green widget", "code": "W-02", "tags": [] }
|
||||
],
|
||||
"meta": { "page": 1, "per_page": 20, "total": 2, "last_page": 1 }
|
||||
}
|
||||
6
admin/tests/setup.ts
Normal file
6
admin/tests/setup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// The served index.html carries the admin base in a meta element; tests use a
|
||||
// non-default base to prove the SPA reads it at runtime (D-02, D-03).
|
||||
const meta = document.createElement('meta')
|
||||
meta.setAttribute('name', 'summer-admin-base')
|
||||
meta.setAttribute('content', '/admin-test')
|
||||
document.head.appendChild(meta)
|
||||
199
admin/tests/smoke/tracer.smoke.test.ts
Normal file
199
admin/tests/smoke/tracer.smoke.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import App from '../../src/App.vue'
|
||||
import LoginView from '../../src/views/LoginView.vue'
|
||||
import { createAdminRouter, safeRedirect } from '../../src/app/router'
|
||||
import { runtime } from '../../src/app/runtime'
|
||||
import { onUnauthorized } from '../../src/api/client'
|
||||
import { clearUser, me, useAuth } from '../../src/state/useAuth'
|
||||
import { loadNavigation, setNavigation } from '../../src/state/useNavigation'
|
||||
import navigation from '../fixtures/navigation.json'
|
||||
import listSchema from '../fixtures/widgets.list-schema.json'
|
||||
import listRows from '../fixtures/widgets.list.json'
|
||||
|
||||
const API = '/admin-test/api/v1'
|
||||
|
||||
const profile = {
|
||||
data: {
|
||||
id: 7,
|
||||
login: 'dev',
|
||||
email: 'dev@example.test',
|
||||
first_name: 'Dana',
|
||||
last_name: 'Dev',
|
||||
is_superuser: false,
|
||||
role: { id: 2, code: 'developer', name: 'Developer' },
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
|
||||
type Routes = Record<string, { status?: number; body: unknown }>
|
||||
|
||||
function mockApi(routes: Routes): Request[] {
|
||||
const calls: Request[] = []
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const request = input as Request
|
||||
calls.push(request)
|
||||
const key = `${request.method} ${new URL(request.url).pathname}`
|
||||
const route = routes[key]
|
||||
const status = route ? (route.status ?? 200) : 404
|
||||
const body = route ? route.body : { error: { code: 'not_found', message: 'Not found', details: {} } }
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
function pathOf(request: Request): string {
|
||||
return new URL(request.url).pathname
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
onUnauthorized(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
describe('runtime', () => {
|
||||
it('reads the admin base and API base from the served meta', () => {
|
||||
expect(runtime.base).toBe('/admin-test')
|
||||
expect(runtime.api).toBe(API)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('posts the CSRF header with same-origin credentials and stores no token', async () => {
|
||||
const calls = mockApi({
|
||||
[`POST ${API}/auth/login`]: {
|
||||
body: { data: { token_type: 'cookie', expires_in: 3600, access_token: 'must-not-be-kept' }, meta: {} },
|
||||
},
|
||||
})
|
||||
const auth = useAuth()
|
||||
|
||||
expect(await auth.login('dev', 'secret')).toBe(true)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const request = calls[0]!
|
||||
expect(request.method).toBe('POST')
|
||||
expect(pathOf(request)).toBe(`${API}/auth/login`)
|
||||
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(request.credentials).toBe('same-origin')
|
||||
expect(await request.json()).toEqual({ login: 'dev', password: 'secret' })
|
||||
expect(auth.expiresIn.value).toBe(3600)
|
||||
expect(localStorage.length).toBe(0)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
expect(document.cookie).not.toContain('must-not-be-kept')
|
||||
expect(JSON.stringify({ user: auth.user.value, expiresIn: auth.expiresIn.value })).not.toContain('must-not-be-kept')
|
||||
})
|
||||
|
||||
it('shows the invalid-credentials alert and marks the password invalid', async () => {
|
||||
mockApi({
|
||||
[`POST ${API}/auth/login`]: {
|
||||
status: 401,
|
||||
body: { error: { code: 'unauthenticated', message: 'Invalid credentials', details: {} } },
|
||||
},
|
||||
})
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push('/login')
|
||||
const wrapper = mount(LoginView, { global: { plugins: [router] } })
|
||||
await wrapper.find('input[name="login"]').setValue('dev')
|
||||
await wrapper.find('input[name="password"]').setValue('wrong')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[name="password"]').attributes('aria-invalid')).toBe('true')
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('redirects', () => {
|
||||
it('accepts only in-app paths that start with exactly one slash', () => {
|
||||
expect(safeRedirect('/acme/demo/widgets?page=2')).toBe('/acme/demo/widgets?page=2')
|
||||
expect(safeRedirect('https://evil.example/steal')).toBeNull()
|
||||
expect(safeRedirect('//evil.example/steal')).toBeNull()
|
||||
expect(safeRedirect('/\\evil.example')).toBeNull()
|
||||
expect(safeRedirect('acme/demo')).toBeNull()
|
||||
expect(safeRedirect(['/acme'])).toBeNull()
|
||||
})
|
||||
|
||||
it('sends an unauthenticated visitor to login with the requested path', async () => {
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push('/acme/demo/widgets?page=2')
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets?page=2')
|
||||
})
|
||||
|
||||
it('reports a 401 on any call other than login', async () => {
|
||||
mockApi({
|
||||
[`GET ${API}/auth/me`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'Unauthenticated', details: {} } } },
|
||||
[`POST ${API}/auth/login`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'Invalid credentials', details: {} } } },
|
||||
})
|
||||
const handler = vi.fn()
|
||||
onUnauthorized(handler)
|
||||
await useAuth().login('dev', 'wrong')
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
await me()
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation shell and list', () => {
|
||||
async function mountApp() {
|
||||
const calls = mockApi({
|
||||
[`GET ${API}/auth/me`]: { body: profile },
|
||||
[`GET ${API}/navigation`]: { body: navigation },
|
||||
[`GET ${API}/acme/demo/widgets/schema/list`]: { body: listSchema },
|
||||
[`GET ${API}/acme/demo/widgets`]: { body: listRows },
|
||||
})
|
||||
await me()
|
||||
await loadNavigation()
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push('/acme/demo/widgets')
|
||||
const wrapper = mount(App, { global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
return { wrapper, calls, router }
|
||||
}
|
||||
|
||||
it('renders navigation grouped by plugin and omits a plugin with an empty side menu', async () => {
|
||||
const { wrapper } = await mountApp()
|
||||
const rail = wrapper.find('nav[aria-label="backend::lang.nav.plugins"]')
|
||||
expect(rail.exists()).toBe(true)
|
||||
const railLinks = rail.findAll('a')
|
||||
expect(railLinks.map((link) => link.text())).toEqual(['Demo', 'Tools'])
|
||||
expect(railLinks[0]!.attributes('aria-current')).toBe('page')
|
||||
expect(railLinks[1]!.attributes('aria-current')).toBeUndefined()
|
||||
expect(railLinks[1]!.attributes('href')).toBe('/acme/tools/hammers')
|
||||
// An unknown icon name renders the neutral fallback instead of failing.
|
||||
expect(railLinks[1]!.find('svg').exists()).toBe(true)
|
||||
|
||||
const panel = wrapper.find('aside')
|
||||
const panelLinks = panel.findAll('a')
|
||||
expect(panel.text()).toContain('Demo')
|
||||
expect(panelLinks.map((link) => link.text())).toEqual(['Widgets', 'Gadgets'])
|
||||
expect(panelLinks[0]!.attributes('aria-current')).toBe('page')
|
||||
expect(panelLinks[1]!.attributes('href')).toBe('/acme/demo/gadgets')
|
||||
})
|
||||
|
||||
it('renders the list schema columns and rows through the typed client', async () => {
|
||||
const { wrapper, calls } = await mountApp()
|
||||
const requested = calls.map(pathOf)
|
||||
expect(requested).toContain(`${API}/acme/demo/widgets/schema/list`)
|
||||
expect(requested).toContain(`${API}/acme/demo/widgets`)
|
||||
for (const request of calls) {
|
||||
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
}
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('Widgets')
|
||||
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code', 'Tags'])
|
||||
const rows = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
|
||||
expect(rows).toEqual([
|
||||
['Blue widget', 'W-01', 'small, round'],
|
||||
['Green widget', 'W-02', '—'],
|
||||
])
|
||||
})
|
||||
})
|
||||
21
admin/tsconfig.json
Normal file
21
admin/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts", "env.d.ts"]
|
||||
}
|
||||
34
admin/vite.config.ts
Normal file
34
admin/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// The committed build is path-agnostic (D-02): assets use a relative base and
|
||||
// index.html keeps the __SUMMER_ADMIN_BASE__ token, which the Go server
|
||||
// (boardwalk) replaces with the configured backend.uri. The dev server
|
||||
// replaces it with SUMMER_ADMIN_DEV_PREFIX and proxies the admin API to a
|
||||
// running `summer serve` at SUMMER_ADMIN_DEV_TARGET.
|
||||
const devPrefix = (process.env.SUMMER_ADMIN_DEV_PREFIX ?? '/backend').replace(/\/+$/, '')
|
||||
const devTarget = process.env.SUMMER_ADMIN_DEV_TARGET ?? 'http://localhost:8080'
|
||||
|
||||
function devBase(): Plugin {
|
||||
return {
|
||||
name: 'summer-admin-dev-base',
|
||||
apply: 'serve',
|
||||
transformIndexHtml: (html) => html.replace('__SUMMER_ADMIN_BASE__', devPrefix),
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
base: command === 'build' ? './' : '/',
|
||||
plugins: [vue(), tailwindcss(), devBase()],
|
||||
build: {
|
||||
outDir: '../boardwalk/dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
[`${devPrefix}/api`]: { target: devTarget, changeOrigin: false },
|
||||
},
|
||||
},
|
||||
}))
|
||||
12
admin/vitest.config.ts
Normal file
12
admin/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
restoreMocks: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user