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

1
admin/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

14
admin/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

4403
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
admin/package.json Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

16
admin/src/api/types.ts Normal file
View 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]

View 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
View 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
View 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
View 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
View 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

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>

30
admin/src/main.ts Normal file
View 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()

View 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,
}
}

View 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
View 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);
}
}

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>

55
admin/tests/fixtures/navigation.json vendored Normal file
View 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" }
}

View 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" }
}

View 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
View 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)

View 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
View 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
View 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
View 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,
},
})