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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,6 +6,9 @@
|
||||
*.out
|
||||
coverage.*
|
||||
|
||||
# Admin SPA
|
||||
/admin/node_modules/
|
||||
|
||||
# Env and local config
|
||||
.env
|
||||
.env.*
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
167
boardwalk/boardwalk.go
Normal file
167
boardwalk/boardwalk.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Package boardwalk serves the embedded admin SPA build (D-01, D-02).
|
||||
//
|
||||
// The committed dist/ is path-agnostic: Vite builds it with a relative base
|
||||
// and index.html carries the __SUMMER_ADMIN_BASE__ token. Handler rewrites
|
||||
// index.html once for the configured backend.uri, serves hashed assets with
|
||||
// long-lived caching, falls back to index.html for client-side routes and
|
||||
// hands every unmatched api/ path back to the caller so API misses stay JSON.
|
||||
package boardwalk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// BaseToken is replaced in dist/index.html by the configured admin prefix.
|
||||
const BaseToken = "__SUMMER_ADMIN_BASE__"
|
||||
|
||||
// contentSecurityPolicy keeps the admin out of frames and allows scripts
|
||||
// only from its own origin; the build contains no inline script.
|
||||
const contentSecurityPolicy = "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'"
|
||||
|
||||
var contentTypes = map[string]string{
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".woff2": "font/woff2",
|
||||
".woff": "font/woff",
|
||||
".svg": "image/svg+xml",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
// Dist returns the embedded build tree rooted at dist/.
|
||||
func Dist() (fs.FS, error) {
|
||||
return fs.Sub(distFS, "dist")
|
||||
}
|
||||
|
||||
// Handler serves the embedded SPA under prefix (for example /backend).
|
||||
// notFoundAPI answers any request whose path under the prefix is api or
|
||||
// starts with api/; it must write the admin API's JSON 404 envelope.
|
||||
func Handler(prefix string, notFoundAPI http.Handler) (http.Handler, error) {
|
||||
root, err := Dist()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newHandler(root, prefix, notFoundAPI)
|
||||
}
|
||||
|
||||
func newHandler(root fs.FS, prefix string, notFoundAPI http.Handler) (http.Handler, error) {
|
||||
if notFoundAPI == nil {
|
||||
return nil, errors.New("boardwalk: notFoundAPI handler is nil")
|
||||
}
|
||||
prefix = strings.TrimRight(prefix, "/")
|
||||
if !strings.HasPrefix(prefix, "/") {
|
||||
return nil, fmt.Errorf("boardwalk: prefix %q must start with /", prefix)
|
||||
}
|
||||
raw, err := fs.ReadFile(root, "index.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("boardwalk: dist/index.html: %w", err)
|
||||
}
|
||||
index, err := RewriteIndex(raw, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &handler{root: root, prefix: prefix, index: index, notFoundAPI: notFoundAPI}, nil
|
||||
}
|
||||
|
||||
// RewriteIndex points relative asset URLs at prefix and injects the prefix
|
||||
// into the summer-admin-base meta. It fails when the token is absent, which
|
||||
// catches a stale or hand-edited dist at boot.
|
||||
func RewriteIndex(raw []byte, prefix string) ([]byte, error) {
|
||||
if !bytes.Contains(raw, []byte(BaseToken)) {
|
||||
return nil, errors.New("boardwalk: dist/index.html has no " + BaseToken + " token; rebuild the admin SPA")
|
||||
}
|
||||
escaped := html.EscapeString(prefix)
|
||||
out := bytes.ReplaceAll(raw, []byte(`="./`), []byte(`="`+escaped+`/`))
|
||||
out = bytes.ReplaceAll(out, []byte(BaseToken), []byte(escaped))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
root fs.FS
|
||||
prefix string
|
||||
index []byte
|
||||
notFoundAPI http.Handler
|
||||
}
|
||||
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
setSecurityHeaders(w.Header())
|
||||
rel := strings.TrimPrefix(r.URL.Path, h.prefix)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if rel == "api" || strings.HasPrefix(rel, "api/") {
|
||||
h.notFoundAPI.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(path.Clean("/"+rel), "/")
|
||||
if name == "" || name == "index.html" {
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
if info, err := fs.Stat(h.root, name); err == nil {
|
||||
if info.Mode().IsRegular() {
|
||||
h.serveFile(w, r, name)
|
||||
return
|
||||
}
|
||||
// A directory is never listed; it is treated as a client route.
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
if path.Ext(name) != "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
h.serveIndex(w, r)
|
||||
}
|
||||
|
||||
func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
|
||||
}
|
||||
|
||||
func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, name string) {
|
||||
body, err := fs.ReadFile(h.root, name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType(name))
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
http.ServeContent(w, r, path.Base(name), time.Time{}, bytes.NewReader(body))
|
||||
}
|
||||
|
||||
func contentType(name string) string {
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
if ct, ok := contentTypes[ext]; ok {
|
||||
return ct
|
||||
}
|
||||
if ct := mime.TypeByExtension(ext); ct != "" {
|
||||
return ct
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func setSecurityHeaders(h http.Header) {
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Content-Security-Policy", contentSecurityPolicy)
|
||||
h.Set("X-Robots-Tag", "noindex, nofollow")
|
||||
}
|
||||
BIN
boardwalk/dist/assets/dm-mono-latin-400-normal--0xN8mdc.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-400-normal--0xN8mdc.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-400-normal-4GdczIuU.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-400-normal-4GdczIuU.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-500-normal-CN8Miw6E.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-500-normal-CN8Miw6E.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-500-normal-DRMDZjhP.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-500-normal-DRMDZjhP.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-ext-400-normal-1aZr6b2b.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-ext-400-normal-1aZr6b2b.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-ext-400-normal-C2zvOubV.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-ext-400-normal-C2zvOubV.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-ext-500-normal-BtRyHRi6.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-ext-500-normal-BtRyHRi6.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-mono-latin-ext-500-normal-Dw3M13d8.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-mono-latin-ext-500-normal-Dw3M13d8.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-400-normal-BwCSEQnW.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-400-normal-BwCSEQnW.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-400-normal-CW0RaeGs.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-400-normal-CW0RaeGs.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-500-normal-B9HHJjqV.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-500-normal-B9HHJjqV.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-500-normal-Dr3UlScf.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-500-normal-Dr3UlScf.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-600-normal-Aqo67rzb.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-600-normal-Aqo67rzb.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-600-normal-BmdmIIQ2.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-600-normal-BmdmIIQ2.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-700-normal-CUSSCpQX.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-700-normal-CUSSCpQX.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-700-normal-DvUfVpUG.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-700-normal-DvUfVpUG.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BjWJ59Pq.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BjWJ59Pq.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BtiwyxMk.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-400-normal-BtiwyxMk.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-500-normal-BJfUCQsA.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-500-normal-BJfUCQsA.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-500-normal-DR84L5F-.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-500-normal-DR84L5F-.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-600-normal-4vooXBpG.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-600-normal-4vooXBpG.woff2
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-600-normal-DRtaDpgU.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-600-normal-DRtaDpgU.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-700-normal-BLI3TTWz.woff
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-700-normal-BLI3TTWz.woff
vendored
Normal file
Binary file not shown.
BIN
boardwalk/dist/assets/dm-sans-latin-ext-700-normal-CJIcxD6K.woff2
vendored
Normal file
BIN
boardwalk/dist/assets/dm-sans-latin-ext-700-normal-CJIcxD6K.woff2
vendored
Normal file
Binary file not shown.
1
boardwalk/dist/assets/index-UTAit0wB.css
vendored
Normal file
1
boardwalk/dist/assets/index-UTAit0wB.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-ZNCn30hM.js
vendored
Normal file
1
boardwalk/dist/assets/index-ZNCn30hM.js
vendored
Normal file
File diff suppressed because one or more lines are too long
15
boardwalk/dist/index.html
vendored
Normal file
15
boardwalk/dist/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!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>
|
||||
<script type="module" crossorigin src="./assets/index-ZNCn30hM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-UTAit0wB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -87,13 +87,16 @@ func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNam
|
||||
return &jwtGuard{secret: secret, users: users, bl: bl, cookieNames: cookieNames}
|
||||
}
|
||||
|
||||
// NewBackendJWTGuard is bearer-only and requires AudienceBackend.
|
||||
// write may replace the PHP-shaped 401 body; nil keeps write401.
|
||||
func NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error)) Guard {
|
||||
// NewBackendJWTGuard requires AudienceBackend. write may replace the
|
||||
// PHP-shaped 401 body; nil keeps write401. With no cookieNames it is
|
||||
// Bearer-only; otherwise each cookie is tried, in order, after the
|
||||
// Authorization header, so a Bearer token still wins when both are sent.
|
||||
func NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error), cookieNames ...string) Guard {
|
||||
return &jwtGuard{
|
||||
secret: secret,
|
||||
users: users,
|
||||
bl: bl,
|
||||
cookieNames: cookieNames,
|
||||
audience: AudienceBackend,
|
||||
requireAudience: true,
|
||||
writeFn: write,
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
package cabana
|
||||
|
||||
// Admin API annotations. swag reads these with the handler package so
|
||||
// docs/openapi.json lists every D-09 route. The functions are not mounted;
|
||||
// service.mount in http.go is the runtime route table, and
|
||||
// TestPhase09PermissionMatrix fails if the two lists diverge.
|
||||
// @title SummerCMS Admin API
|
||||
// @version 1
|
||||
// @description Framework admin API consumed by the embedded admin SPA. Every path is relative to {backend.uri}/api/v1 (for example /backend/api/v1). The SPA authenticates with the HttpOnly summer_admin cookie set by a login that sends X-Requested-With: XMLHttpRequest, and sends that header on every request; CLI clients and tests send the BackendBearer Authorization header instead.
|
||||
// @BasePath /
|
||||
// @securityDefinitions.apikey BackendBearer
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Backend admin bearer token. Send "Bearer {access_token}".
|
||||
|
||||
// Admin API annotations. scripts/check-admin-openapi.sh reads them with swag
|
||||
// to produce admin/openapi/admin.json, the document the SPA's TypeScript types
|
||||
// are generated from (D-15). The functions are not mounted; service.mount in
|
||||
// http.go is the runtime route table, and TestPhase09PermissionMatrix plus
|
||||
// TestPhase09ContractInventory fail if the two lists diverge.
|
||||
|
||||
// ErrorBody is one D-10 error object.
|
||||
type ErrorBody struct {
|
||||
@@ -32,41 +42,84 @@ type SuccessEnvelope struct {
|
||||
Meta SuccessMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// AdminLoginData is the admin login payload.
|
||||
// AdminLoginData is the admin login and refresh payload. Bearer transport
|
||||
// carries access_token; cookie transport (X-Requested-With: XMLHttpRequest)
|
||||
// carries token_type "cookie" and expires_in, never the token.
|
||||
type AdminLoginData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLoginEnvelope is the admin login success body.
|
||||
type AdminLoginEnvelope struct {
|
||||
Data AdminLoginData `json:"data"`
|
||||
// Envelope is the typed D-10 success envelope.
|
||||
type Envelope[T any] struct {
|
||||
Data T `json:"data"`
|
||||
Meta SuccessMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// AdminLogin documents POST /_admin/api/v1/auth/login.
|
||||
// ListEnvelope is the typed D-10 paginated envelope (Phase 9 D-11 meta).
|
||||
type ListEnvelope[T any] struct {
|
||||
Data T `json:"data"`
|
||||
Meta ListMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// AdminRecord is one admin record: a string-keyed map read through its
|
||||
// list or form schema (D-16).
|
||||
type AdminRecord map[string]any
|
||||
|
||||
// AdminLoginRequest is the admin login body. Either login or email
|
||||
// identifies the backend user.
|
||||
type AdminLoginRequest struct {
|
||||
Login string `json:"login,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// AdminRoleSummary is the role attached to an admin profile.
|
||||
type AdminRoleSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AdminProfile is the GET /auth/me payload.
|
||||
type AdminProfile struct {
|
||||
ID uint `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
IsSuperuser bool `json:"is_superuser"`
|
||||
Role *AdminRoleSummary `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// AdminLogin documents POST /auth/login.
|
||||
//
|
||||
// @Summary Admin login
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} AdminLoginEnvelope
|
||||
// @Param body body AdminLoginRequest true "Credentials"
|
||||
// @Param X-Requested-With header string false "XMLHttpRequest selects cookie transport"
|
||||
// @Success 200 {object} Envelope[AdminLoginData]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/auth/login [post]
|
||||
// @Router /auth/login [post]
|
||||
func AdminLogin() {}
|
||||
|
||||
// AdminRefresh documents POST /_admin/api/v1/auth/refresh.
|
||||
// AdminRefresh documents POST /auth/refresh.
|
||||
//
|
||||
// @Summary Refresh an admin token
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} AdminLoginEnvelope
|
||||
// @Param X-Requested-With header string false "XMLHttpRequest; required unless a Bearer token is sent"
|
||||
// @Success 200 {object} Envelope[AdminLoginData]
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/auth/refresh [post]
|
||||
// @Router /auth/refresh [post]
|
||||
func AdminRefresh() {}
|
||||
|
||||
// AdminLogout documents POST /_admin/api/v1/auth/logout.
|
||||
// AdminLogout documents POST /auth/logout.
|
||||
//
|
||||
// @Summary Admin logout
|
||||
// @Tags admin
|
||||
@@ -74,33 +127,33 @@ func AdminRefresh() {}
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/auth/logout [post]
|
||||
// @Router /auth/logout [post]
|
||||
func AdminLogout() {}
|
||||
|
||||
// AdminMe documents GET /_admin/api/v1/auth/me.
|
||||
// AdminMe documents GET /auth/me.
|
||||
//
|
||||
// @Summary Current admin
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[AdminProfile]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/auth/me [get]
|
||||
// @Router /auth/me [get]
|
||||
func AdminMe() {}
|
||||
|
||||
// AdminNavigation documents GET /_admin/api/v1/navigation.
|
||||
// AdminNavigation documents GET /navigation.
|
||||
//
|
||||
// @Summary Admin navigation
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Security BackendBearer
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[[]NavigationEntry]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/navigation [get]
|
||||
// @Router /navigation [get]
|
||||
func AdminNavigation() {}
|
||||
|
||||
// AdminSettingsList documents GET /_admin/api/v1/settings.
|
||||
// AdminSettingsList documents GET /settings.
|
||||
//
|
||||
// @Summary List admin settings
|
||||
// @Tags admin
|
||||
@@ -109,10 +162,10 @@ func AdminNavigation() {}
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/settings [get]
|
||||
// @Router /settings [get]
|
||||
func AdminSettingsList() {}
|
||||
|
||||
// AdminSettingsSchema documents GET /_admin/api/v1/settings/{code}/schema.
|
||||
// AdminSettingsSchema documents GET /settings/{code}/schema.
|
||||
//
|
||||
// @Summary Admin settings schema
|
||||
// @Tags admin
|
||||
@@ -123,10 +176,10 @@ func AdminSettingsList() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/settings/{code}/schema [get]
|
||||
// @Router /settings/{code}/schema [get]
|
||||
func AdminSettingsSchema() {}
|
||||
|
||||
// AdminSettingsGet documents GET /_admin/api/v1/settings/{code}.
|
||||
// AdminSettingsGet documents GET /settings/{code}.
|
||||
//
|
||||
// @Summary Read admin settings
|
||||
// @Tags admin
|
||||
@@ -137,10 +190,10 @@ func AdminSettingsSchema() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/settings/{code} [get]
|
||||
// @Router /settings/{code} [get]
|
||||
func AdminSettingsGet() {}
|
||||
|
||||
// AdminSettingsPut documents PUT /_admin/api/v1/settings/{code}.
|
||||
// AdminSettingsPut documents PUT /settings/{code}.
|
||||
//
|
||||
// @Summary Update admin settings
|
||||
// @Tags admin
|
||||
@@ -152,7 +205,7 @@ func AdminSettingsGet() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/settings/{code} [put]
|
||||
// @Router /settings/{code} [put]
|
||||
func AdminSettingsPut() {}
|
||||
|
||||
// AdminListSchema documents the list schema route.
|
||||
@@ -164,11 +217,11 @@ func AdminSettingsPut() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Success 200 {object} Envelope[ListSchema]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/schema/list [get]
|
||||
func AdminListSchema() {}
|
||||
|
||||
// AdminFormSchema documents the form schema route.
|
||||
@@ -184,7 +237,7 @@ func AdminListSchema() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/schema/form [get]
|
||||
func AdminFormSchema() {}
|
||||
|
||||
// AdminRelationSchema documents the relation schema route.
|
||||
@@ -201,7 +254,7 @@ func AdminFormSchema() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
|
||||
func AdminRelationSchema() {}
|
||||
|
||||
// AdminList documents the record list route.
|
||||
@@ -213,11 +266,16 @@ func AdminRelationSchema() {}
|
||||
// @Param vendor path string true "Vendor"
|
||||
// @Param plugin path string true "Plugin"
|
||||
// @Param controller path string true "Controller"
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Param search query string false "Search term"
|
||||
// @Param sort query string false "Sort column"
|
||||
// @Param dir query string false "Sort direction (asc or desc)"
|
||||
// @Param page query integer false "Page"
|
||||
// @Param per_page query integer false "Records per page"
|
||||
// @Success 200 {object} ListEnvelope[[]AdminRecord]
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [get]
|
||||
// @Router /{vendor}/{plugin}/{controller} [get]
|
||||
func AdminList() {}
|
||||
|
||||
// AdminCreate documents the record create route.
|
||||
@@ -234,7 +292,7 @@ func AdminList() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [post]
|
||||
// @Router /{vendor}/{plugin}/{controller} [post]
|
||||
func AdminCreate() {}
|
||||
|
||||
// AdminBulkDelete documents the bulk delete route.
|
||||
@@ -251,7 +309,7 @@ func AdminCreate() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete [post]
|
||||
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
|
||||
func AdminBulkDelete() {}
|
||||
|
||||
// AdminShow documents the record show route.
|
||||
@@ -268,7 +326,7 @@ func AdminBulkDelete() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id} [get]
|
||||
func AdminShow() {}
|
||||
|
||||
// AdminUpdate documents the record update route.
|
||||
@@ -286,7 +344,7 @@ func AdminShow() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [put]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id} [put]
|
||||
func AdminUpdate() {}
|
||||
|
||||
// AdminDelete documents the record delete route.
|
||||
@@ -303,7 +361,7 @@ func AdminUpdate() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [delete]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id} [delete]
|
||||
func AdminDelete() {}
|
||||
|
||||
// AdminRelationLinked documents the linked-relation route.
|
||||
@@ -320,7 +378,7 @@ func AdminDelete() {}
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
|
||||
func AdminRelationLinked() {}
|
||||
|
||||
// AdminRelationCandidates documents the relation candidate route.
|
||||
@@ -337,7 +395,7 @@ func AdminRelationLinked() {}
|
||||
// @Success 200 {object} SuccessEnvelope
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
|
||||
func AdminRelationCandidates() {}
|
||||
|
||||
// AdminRelationLink documents the relation link route.
|
||||
@@ -356,7 +414,7 @@ func AdminRelationCandidates() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
|
||||
func AdminRelationLink() {}
|
||||
|
||||
// AdminRelationUnlink documents the relation unlink route.
|
||||
@@ -375,5 +433,5 @@ func AdminRelationLink() {}
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 422 {object} ErrorEnvelope
|
||||
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
|
||||
// @Router /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
|
||||
func AdminRelationUnlink() {}
|
||||
|
||||
7
cabana/admin_paths_test.go
Normal file
7
cabana/admin_paths_test.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package cabana
|
||||
|
||||
// adminAPI composes a full admin API path under the default prefix, so tests
|
||||
// follow the backend.uri scheme (D-03) instead of hardcoding it.
|
||||
func adminAPI(rel string) string {
|
||||
return DefaultAdminPrefix + adminAPIVersion + rel
|
||||
}
|
||||
@@ -172,12 +172,39 @@ func (s *service) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logAuth(r, "success", user.ID)
|
||||
if isAjax(r) {
|
||||
// Cookie transport (D-19): the SPA never sees the token.
|
||||
s.writeSessionCookie(w, token)
|
||||
WriteData(w, http.StatusOK, cookieLoginData(s.ttl), map[string]any{})
|
||||
return
|
||||
}
|
||||
WriteData(w, http.StatusOK, map[string]string{
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
}, map[string]any{})
|
||||
}
|
||||
|
||||
// cookieLoginData is the login/refresh body under cookie transport: no token,
|
||||
// only its type and the access lifetime in seconds.
|
||||
func cookieLoginData(ttl time.Duration) AdminLoginData {
|
||||
return AdminLoginData{TokenType: "cookie", ExpiresIn: int(ttl / time.Second)}
|
||||
}
|
||||
|
||||
// writeSessionCookie sets the admin JWT cookie scoped to the admin prefix.
|
||||
// Max-Age is the refresh window, because refresh accepts an expired access
|
||||
// token until iat plus refresh_ttl.
|
||||
func (s *service) writeSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AdminCookieName,
|
||||
Value: token,
|
||||
Path: s.adminPrefix(),
|
||||
MaxAge: int(s.refreshTTL / time.Second),
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
raw := bearerToken(r)
|
||||
if raw == "" {
|
||||
@@ -247,23 +274,19 @@ func (s *service) me(w http.ResponseWriter, r *http.Request) {
|
||||
WriteData(w, http.StatusOK, profileOf(user), map[string]any{})
|
||||
}
|
||||
|
||||
func profileOf(user BackendUser) map[string]any {
|
||||
data := map[string]any{
|
||||
"id": user.ID,
|
||||
"login": user.Login,
|
||||
"email": user.Email,
|
||||
"first_name": user.FirstName,
|
||||
"last_name": user.LastName,
|
||||
"is_superuser": user.IsSuperuser,
|
||||
func profileOf(user BackendUser) AdminProfile {
|
||||
profile := AdminProfile{
|
||||
ID: user.ID,
|
||||
Login: user.Login,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
IsSuperuser: user.IsSuperuser,
|
||||
}
|
||||
if user.Role.ID != 0 {
|
||||
data["role"] = map[string]any{
|
||||
"id": user.Role.ID,
|
||||
"code": user.Role.Code,
|
||||
"name": user.Role.Name,
|
||||
profile.Role = &AdminRoleSummary{ID: user.Role.ID, Code: user.Role.Code, Name: user.Role.Name}
|
||||
}
|
||||
}
|
||||
return data
|
||||
return profile
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
@@ -402,15 +425,18 @@ func adminLoginWindow(app *backpack.App) (int, int) {
|
||||
return maxAttempts, decayMinutes
|
||||
}
|
||||
|
||||
func adminIssuer(app *backpack.App) string {
|
||||
// adminIssuer is app.url plus the admin API login path. JWT verification does
|
||||
// not check iss, so tokens minted under an earlier prefix stay valid until
|
||||
// they expire.
|
||||
func adminIssuer(app *backpack.App, prefix string) string {
|
||||
base := ""
|
||||
if app != nil && app.Config != nil {
|
||||
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
|
||||
}
|
||||
if base == "" {
|
||||
return "/_admin/api/v1/auth/login"
|
||||
if prefix == "" {
|
||||
prefix = DefaultAdminPrefix
|
||||
}
|
||||
return base + "/_admin/api/v1/auth/login"
|
||||
return base + prefix + adminAPIVersion + "/auth/login"
|
||||
}
|
||||
|
||||
// dummyPasswordHash keeps a missing-user login on the bcrypt path.
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
user := insertAdmin(t, gdb, "life", "Life@Example.Test", adminTestPassword, true, false)
|
||||
login := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
login := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "life",
|
||||
"password": adminTestPassword,
|
||||
})
|
||||
@@ -63,7 +63,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if !stamped.Valid {
|
||||
t.Fatal("successful login did not stamp last_login")
|
||||
}
|
||||
byEmail := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
byEmail := postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"email": "life@example.test",
|
||||
"password": adminTestPassword,
|
||||
})
|
||||
@@ -71,12 +71,12 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
t.Fatalf("email login status=%d body=%s", byEmail.Code, byEmail.Body.String())
|
||||
}
|
||||
emailToken := accessToken(t, byEmail.Body.Bytes())
|
||||
me := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
|
||||
me := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
||||
if me.Code != http.StatusOK {
|
||||
t.Fatalf("me status=%d body=%s", me.Code, me.Body.String())
|
||||
}
|
||||
assertSafeProfile(t, me.Body.Bytes(), user)
|
||||
refreshed := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", emailToken, nil)
|
||||
refreshed := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), emailToken, nil)
|
||||
if refreshed.Code != http.StatusOK {
|
||||
t.Fatalf("refresh status=%d body=%s", refreshed.Code, refreshed.Body.String())
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if jwtAudience(t, next) != "backend" {
|
||||
t.Fatal("refreshed token lost the backend audience")
|
||||
}
|
||||
oldMe := getAuth(t, h, "/_admin/api/v1/auth/me", emailToken)
|
||||
oldMe := getAuth(t, h, adminAPI("/auth/me"), emailToken)
|
||||
if oldMe.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("previous token after refresh status=%d body=%s", oldMe.Code, oldMe.Body.String())
|
||||
}
|
||||
@@ -98,14 +98,14 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if blacklisted != 1 {
|
||||
t.Fatalf("previous jti blacklist rows=%d", blacklisted)
|
||||
}
|
||||
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", next, nil)
|
||||
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), next, nil)
|
||||
if out.Code != http.StatusOK {
|
||||
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
||||
}
|
||||
if strings.Contains(out.Body.String(), next) {
|
||||
t.Fatal("logout body contains the token")
|
||||
}
|
||||
after := getAuth(t, h, "/_admin/api/v1/auth/me", next)
|
||||
after := getAuth(t, h, adminAPI("/auth/me"), next)
|
||||
if after.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("me after logout status=%d", after.Code)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", cutoff).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
fresh := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "life", "password": adminTestPassword,
|
||||
}).Body.Bytes())
|
||||
// Login mints after the cutoff, so this token is current. Move the cutoff
|
||||
@@ -121,7 +121,7 @@ func TestAdminAuthLifecycle(t *testing.T) {
|
||||
if err := gdb.Model(&cabana.BackendUser{}).Where("id = ?", user.ID).Update("tokens_valid_after", time.Now().Add(time.Hour)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := getAuth(t, h, "/_admin/api/v1/auth/me", fresh)
|
||||
stale := getAuth(t, h, adminAPI("/auth/me"), fresh)
|
||||
if stale.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("stale principal status=%d body=%s", stale.Code, stale.Body.String())
|
||||
}
|
||||
@@ -131,9 +131,9 @@ func TestAdminInactive(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "inactive", "inactive@example.test", adminTestPassword, false, false)
|
||||
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody", "password": adminTestPassword})
|
||||
wrong := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": "wrong-password"})
|
||||
right := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "inactive", "password": adminTestPassword})
|
||||
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody", "password": adminTestPassword})
|
||||
wrong := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": "wrong-password"})
|
||||
right := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "inactive", "password": adminTestPassword})
|
||||
assertSameOpaque(t, unknown, wrong, right)
|
||||
var stamped sql.NullTime
|
||||
if err := gdb.Raw(`SELECT last_login FROM backend_users WHERE login = 'inactive'`).Scan(&stamped).Error; err != nil {
|
||||
@@ -148,8 +148,8 @@ func TestAdminDeleted(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "deleted", "deleted@example.test", adminTestPassword, true, true)
|
||||
unknown := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "nobody-else", "password": adminTestPassword})
|
||||
deleted := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "deleted", "password": adminTestPassword})
|
||||
unknown := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "nobody-else", "password": adminTestPassword})
|
||||
deleted := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "deleted", "password": adminTestPassword})
|
||||
assertSameOpaque(t, unknown, deleted)
|
||||
if strings.Contains(strings.ToLower(deleted.Body.String()), "delet") {
|
||||
t.Fatalf("deleted login disclosed the account: %s", deleted.Body.String())
|
||||
@@ -160,14 +160,14 @@ func TestAdminBlacklist(t *testing.T) {
|
||||
gdb := adminGorm(t)
|
||||
h := adminHandler(t, gdb, nil)
|
||||
insertAdmin(t, gdb, "revoke", "revoke@example.test", adminTestPassword, true, false)
|
||||
token := accessToken(t, postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
token := accessToken(t, postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "revoke", "password": adminTestPassword,
|
||||
}).Body.Bytes())
|
||||
out := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/logout", token, nil)
|
||||
out := postAuth(t, h, http.MethodPost, adminAPI("/auth/logout"), token, nil)
|
||||
if out.Code != http.StatusOK {
|
||||
t.Fatalf("logout status=%d body=%s", out.Code, out.Body.String())
|
||||
}
|
||||
again := postAuth(t, h, http.MethodPost, "/_admin/api/v1/auth/refresh", token, nil)
|
||||
again := postAuth(t, h, http.MethodPost, adminAPI("/auth/refresh"), token, nil)
|
||||
if again.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh after logout status=%d body=%s", again.Code, again.Body.String())
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func TestAdminLoginThrottle(t *testing.T) {
|
||||
})
|
||||
var last *httptest.ResponseRecorder
|
||||
for i := 0; i < 3; i++ {
|
||||
last = postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{
|
||||
last = postJSON(t, h, adminAPI("/auth/login"), map[string]string{
|
||||
"login": "throttle-user", "password": adminTestPassword,
|
||||
})
|
||||
}
|
||||
@@ -217,16 +217,16 @@ func TestAdminAuthLogging(t *testing.T) {
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
ok := postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": adminTestPassword})
|
||||
ok := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": adminTestPassword})
|
||||
token := accessToken(t, ok.Body.Bytes())
|
||||
assertLog(t, &buf, "success", user.ID, adminTestPassword, token)
|
||||
buf.Reset()
|
||||
|
||||
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "logged", "password": "not-the-password"})
|
||||
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "logged", "password": "not-the-password"})
|
||||
assertLog(t, &buf, "failed", user.ID, "not-the-password", "")
|
||||
buf.Reset()
|
||||
|
||||
postJSON(t, h, "/_admin/api/v1/auth/login", map[string]string{"login": "missing-logged", "password": "not-the-password"})
|
||||
postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": "missing-logged", "password": "not-the-password"})
|
||||
failedUnknown := buf.String()
|
||||
if !strings.Contains(failedUnknown, `"outcome":"failed"`) {
|
||||
t.Fatalf("unknown login log = %s", failedUnknown)
|
||||
@@ -236,7 +236,7 @@ func TestAdminAuthLogging(t *testing.T) {
|
||||
}
|
||||
buf.Reset()
|
||||
|
||||
denied := getAuth(t, h, "/_admin/api/v1/acme/demo/widgets", token)
|
||||
denied := getAuth(t, h, adminAPI("/acme/demo/widgets"), token)
|
||||
if denied.Code != http.StatusForbidden {
|
||||
t.Fatalf("denied status=%d body=%s", denied.Code, denied.Body.String())
|
||||
}
|
||||
@@ -551,3 +551,9 @@ func jwtClaims(t *testing.T, token string) map[string]any {
|
||||
func itoa(id uint) string {
|
||||
return strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
|
||||
// adminAPI mirrors the internal helper in admin_paths_test.go for this
|
||||
// external test package: a full admin API path under the default prefix.
|
||||
func adminAPI(rel string) string {
|
||||
return cabana.DefaultAdminPrefix + "/api/v1" + rel
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestBulkDeleteEmpty(t *testing.T) {
|
||||
cap := &captureRouter{}
|
||||
(&service{}).mount(cap)
|
||||
key := "POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete"
|
||||
key := "POST " + adminAPI("/{vendor}/{plugin}/{controller}/bulk-delete")
|
||||
mw, ok := cap.middleware[key]
|
||||
if !ok || !containsString(mw, "backend") {
|
||||
t.Fatalf("missing %s in %v", key, cap.routes)
|
||||
|
||||
@@ -107,7 +107,7 @@ func TestAdminResetPasswordCommand(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guard := bouncer.NewBackendJWTGuard(adminTestSecret, cabana.BackendUsers{DB: gdb}, nil, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/auth/me", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, adminAPI("/auth/me"), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if _, err := guard.Authenticate(req); err != nil {
|
||||
t.Fatalf("token before reset: %v", err)
|
||||
|
||||
@@ -104,10 +104,10 @@ func TestCRUDRecordRoutes(t *testing.T) {
|
||||
cap := &captureRouter{}
|
||||
(&service{}).mount(cap)
|
||||
for _, want := range []string{
|
||||
"POST /_admin/api/v1/{vendor}/{plugin}/{controller}",
|
||||
"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
|
||||
"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
|
||||
"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}",
|
||||
"POST " + adminAPI("/{vendor}/{plugin}/{controller}"),
|
||||
"GET " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
|
||||
"PUT " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
|
||||
"DELETE " + adminAPI("/{vendor}/{plugin}/{controller}/{id}"),
|
||||
} {
|
||||
mw, ok := cap.middleware[want]
|
||||
if !ok {
|
||||
|
||||
45
cabana/csrf.go
Normal file
45
cabana/csrf.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// requestedWithHeader is the custom header the admin SPA sends on every
|
||||
// request. A cross-site form or navigation cannot set it, and a
|
||||
// cross-origin fetch that sets it needs a CORS preflight the admin API
|
||||
// never answers (D-19).
|
||||
requestedWithHeader = "X-Requested-With"
|
||||
requestedWithAjax = "XMLHttpRequest"
|
||||
)
|
||||
|
||||
// requireAjax refuses a state-changing admin request that is not
|
||||
// Bearer-authenticated and does not carry X-Requested-With: XMLHttpRequest.
|
||||
// It runs before the wrapped handler, so a refused request is never decoded,
|
||||
// never looks up a controller and never reaches the database. The response
|
||||
// uses the fixed D-10 code forbidden.
|
||||
func requireAjax(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !csrfSafe(r) {
|
||||
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfSafe(r *http.Request) bool {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return true
|
||||
}
|
||||
if bearerToken(r) != "" {
|
||||
return true
|
||||
}
|
||||
return isAjax(r)
|
||||
}
|
||||
|
||||
func isAjax(r *http.Request) bool {
|
||||
return strings.TrimSpace(r.Header.Get(requestedWithHeader)) == requestedWithAjax
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/boardwalk"
|
||||
"git.golem15.com/golem15/summercms/bouncer"
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
"git.golem15.com/golem15/summercms/party"
|
||||
@@ -20,10 +21,12 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Routes is the raw admin API mounted by surf.BuildRouter.
|
||||
// Routes is the raw admin API and SPA mounted by surf.BuildRouter. Prefix is
|
||||
// the normalized backend.uri every admin route lives under.
|
||||
type Routes struct {
|
||||
Middleware pact.Middleware
|
||||
Mount func(r pact.Router)
|
||||
Prefix string
|
||||
}
|
||||
|
||||
type service struct {
|
||||
@@ -38,6 +41,21 @@ type service struct {
|
||||
loginDecay int
|
||||
issuer string
|
||||
bl bouncer.BlacklistStore
|
||||
prefix string
|
||||
spa http.Handler
|
||||
}
|
||||
|
||||
// adminPrefix returns the mount path; a zero service uses the default.
|
||||
func (s *service) adminPrefix() string {
|
||||
if s == nil || s.prefix == "" {
|
||||
return DefaultAdminPrefix
|
||||
}
|
||||
return s.prefix
|
||||
}
|
||||
|
||||
// apiBase is the admin API root: the prefix plus /api/v1 (D-03).
|
||||
func (s *service) apiBase() string {
|
||||
return s.adminPrefix() + adminAPIVersion
|
||||
}
|
||||
|
||||
// Activate compiles admin controllers and, when any exist, requires
|
||||
@@ -54,6 +72,10 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix, err := AdminPrefix(app)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reg, err := compileRegistry(items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -72,7 +94,7 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
}
|
||||
}
|
||||
bl := adminBlacklist(app)
|
||||
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated)
|
||||
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app, reg: reg}, bl, writeUnauthenticated, AdminCookieName)
|
||||
if _, err := guards.Middleware("backend"); err != nil {
|
||||
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
|
||||
return nil, err
|
||||
@@ -93,10 +115,31 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||
bcryptCost: adminBcryptCost(app),
|
||||
loginMax: loginMax,
|
||||
loginDecay: loginDecay,
|
||||
issuer: adminIssuer(app),
|
||||
issuer: adminIssuer(app, prefix),
|
||||
bl: bl,
|
||||
prefix: prefix,
|
||||
}
|
||||
return &Routes{Middleware: mw, Mount: svc.mount}, nil
|
||||
spa, err := boardwalk.Handler(prefix, http.HandlerFunc(writeNotFound))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cabana: admin SPA: %w", err)
|
||||
}
|
||||
svc.spa = spa
|
||||
return &Routes{Middleware: mw, Mount: svc.mount, Prefix: prefix}, nil
|
||||
}
|
||||
|
||||
func writeNotFound(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||
}
|
||||
|
||||
// serveSPA answers GET {prefix} and GET {prefix}/{path...} from the embedded
|
||||
// build. API paths that no route matched fall through to it and receive the
|
||||
// D-10 not_found envelope, never index.html.
|
||||
func (s *service) serveSPA(w http.ResponseWriter, r *http.Request) {
|
||||
if s == nil || s.spa == nil {
|
||||
writeNotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.spa.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func writeUnauthenticated(w http.ResponseWriter, _ error) {
|
||||
@@ -121,12 +164,15 @@ func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Princ
|
||||
|
||||
func (s *service) mount(r pact.Router) {
|
||||
throttle := fmt.Sprintf("throttle:%d,%d", s.loginMax, s.loginDecay)
|
||||
r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) {
|
||||
api := s.apiBase()
|
||||
r.GroupRaw(api+"/auth", nil, func(g pact.Router) {
|
||||
// Login is exempt from the CSRF header: without it the response is a
|
||||
// Bearer body and no cookie is set, so a cross-site post gains nothing.
|
||||
g.Post("/login", s.login, throttle)
|
||||
g.Post("/refresh", s.refresh)
|
||||
g.Post("/refresh", requireAjax(s.refresh))
|
||||
})
|
||||
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {
|
||||
g.Post("/auth/logout", s.logout)
|
||||
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
|
||||
g.Post("/auth/logout", requireAjax(s.logout))
|
||||
g.Get("/auth/me", s.me)
|
||||
g.Get("/navigation", s.navigation)
|
||||
g.Get("/settings", s.settingsList)
|
||||
@@ -134,7 +180,7 @@ func (s *service) mount(r pact.Router) {
|
||||
constrainSetting(g)
|
||||
g.Get("/settings/{code}", s.settingsGet)
|
||||
constrainSetting(g)
|
||||
g.Put("/settings/{code}", s.settingsPut)
|
||||
g.Put("/settings/{code}", requireAjax(s.settingsPut))
|
||||
constrainSetting(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
|
||||
constrainController(g)
|
||||
@@ -144,25 +190,31 @@ func (s *service) mount(r pact.Router) {
|
||||
constrainRelation(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}", s.list)
|
||||
constrainController(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}", s.create)
|
||||
g.Post("/{vendor}/{plugin}/{controller}", requireAjax(s.create))
|
||||
constrainController(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", s.bulkDelete)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}", s.show)
|
||||
constrainController(g)
|
||||
g.Put("/{vendor}/{plugin}/{controller}/{id}", s.update)
|
||||
g.Put("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.update))
|
||||
constrainController(g)
|
||||
g.Delete("/{vendor}/{plugin}/{controller}/{id}", s.deleteRecord)
|
||||
g.Delete("/{vendor}/{plugin}/{controller}/{id}", requireAjax(s.deleteRecord))
|
||||
constrainController(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}", s.relationLinked)
|
||||
constrainRelation(g)
|
||||
g.Get("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", s.relationCandidates)
|
||||
constrainRelation(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", s.relationLink)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
|
||||
constrainRelation(g)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", s.relationUnlink)
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", requireAjax(s.relationUnlink))
|
||||
constrainRelation(g)
|
||||
})
|
||||
// The SPA shell: public, no guard. ServeMux prefers every API pattern
|
||||
// above over the {path...} wildcard.
|
||||
r.GroupRaw(s.adminPrefix(), nil, func(g pact.Router) {
|
||||
g.Get("", s.serveSPA)
|
||||
g.Get("/{path...}", s.serveSPA)
|
||||
})
|
||||
}
|
||||
|
||||
func constrainController(g pact.Router) {
|
||||
|
||||
@@ -9,14 +9,15 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPhase09ContractInventory fails when the committed OpenAPI document
|
||||
// drops a D-09 route or a protected route's 401 response.
|
||||
// TestPhase09ContractInventory fails when the committed framework admin
|
||||
// OpenAPI document (admin/openapi/admin.json, D-15) drops an admin API route
|
||||
// or a protected route's 401 response. Paths are prefix-relative (D-03).
|
||||
func TestPhase09ContractInventory(t *testing.T) {
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("caller")
|
||||
}
|
||||
specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "fonoteka.go", "docs", "openapi.json"))
|
||||
specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "admin", "openapi", "admin.json"))
|
||||
raw, err := os.ReadFile(specPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", specPath, err)
|
||||
@@ -37,11 +38,16 @@ func TestPhase09ContractInventory(t *testing.T) {
|
||||
t.Fatal("openapi is missing the BackendBearer scheme")
|
||||
}
|
||||
public := map[string]bool{
|
||||
"POST /_admin/api/v1/auth/login": true,
|
||||
"POST /_admin/api/v1/auth/refresh": true,
|
||||
"POST /auth/login": true,
|
||||
"POST /auth/refresh": true,
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
apiRoutes := 0
|
||||
for _, route := range phase09Routes {
|
||||
if route.spa {
|
||||
continue
|
||||
}
|
||||
apiRoutes++
|
||||
method, path, ok := splitRoute(route.key)
|
||||
if !ok {
|
||||
t.Fatalf("bad route key %s", route.key)
|
||||
@@ -76,8 +82,11 @@ func TestPhase09ContractInventory(t *testing.T) {
|
||||
t.Fatalf("%s has no 401 response", key)
|
||||
}
|
||||
}
|
||||
if len(seen) != len(phase09Routes) {
|
||||
t.Fatalf("contract routes=%d want %d", len(seen), len(phase09Routes))
|
||||
if len(seen) != apiRoutes {
|
||||
t.Fatalf("contract routes=%d want %d", len(seen), apiRoutes)
|
||||
}
|
||||
if len(spec.Paths) != len(pathsOf(phase09Routes)) {
|
||||
t.Fatalf("openapi lists %d paths, the mounted API has %d", len(spec.Paths), len(pathsOf(phase09Routes)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,3 +98,20 @@ func splitRoute(key string) (method, path string, ok bool) {
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func pathsOf(routes []struct {
|
||||
key string
|
||||
public bool
|
||||
spa bool
|
||||
}) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, route := range routes {
|
||||
if route.spa {
|
||||
continue
|
||||
}
|
||||
if _, path, ok := splitRoute(route.key); ok {
|
||||
out[path] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
49
cabana/prefix.go
Normal file
49
cabana/prefix.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
)
|
||||
|
||||
// DefaultAdminPrefix is the admin mount path when backend.uri is unset.
|
||||
// It matches WinterCMS's backendUri default.
|
||||
const DefaultAdminPrefix = "/backend"
|
||||
|
||||
// AdminCookieName carries the admin JWT for the embedded SPA (D-19).
|
||||
const AdminCookieName = "summer_admin"
|
||||
|
||||
// adminAPIVersion is appended to the prefix for every admin API route.
|
||||
const adminAPIVersion = "/api/v1"
|
||||
|
||||
var adminPrefixPattern = regexp.MustCompile(`^(/[a-z0-9][a-z0-9_-]*)+$`)
|
||||
|
||||
// AdminPrefix reads backend.uri and returns the normalized admin mount path.
|
||||
// Spaces are trimmed, a leading slash is added and trailing slashes are
|
||||
// removed; an empty value falls back to DefaultAdminPrefix. Every segment
|
||||
// must be lowercase letters, digits, '-' or '_' and start with a letter or
|
||||
// digit, so "/" alone, uppercase, spaces and dot segments are rejected.
|
||||
func AdminPrefix(app *backpack.App) (string, error) {
|
||||
raw := ""
|
||||
if app != nil && app.Config != nil {
|
||||
raw = app.Config.String("backend.uri")
|
||||
}
|
||||
return normalizeAdminPrefix(raw)
|
||||
}
|
||||
|
||||
func normalizeAdminPrefix(raw string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return DefaultAdminPrefix, nil
|
||||
}
|
||||
if !strings.HasPrefix(value, "/") {
|
||||
value = "/" + value
|
||||
}
|
||||
value = strings.TrimRight(value, "/")
|
||||
if value == "" || !adminPrefixPattern.MatchString(value) {
|
||||
return "", fmt.Errorf("cabana: backend.uri %q is invalid: use one or more lowercase path segments such as /backend (set SUMMER_BACKEND__URI)", raw)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -12,35 +12,49 @@ import (
|
||||
"git.golem15.com/golem15/summercms/pact"
|
||||
)
|
||||
|
||||
// phase09Routes is the D-09 admin surface mounted by service.mount.
|
||||
// A handler added outside this set, or a protected handler missing the
|
||||
// backend guard, fails TestPhase09PermissionMatrix.
|
||||
// phase09Routes is the admin surface mounted by service.mount. API keys are
|
||||
// method plus the path relative to {backend.uri}/api/v1 (D-03); spa entries
|
||||
// are the public SPA shell routes relative to {backend.uri} and are not part
|
||||
// of the OpenAPI inventory. A handler added outside this set, or a protected
|
||||
// handler missing the backend guard, fails TestPhase09PermissionMatrix.
|
||||
var phase09Routes = []struct {
|
||||
key string
|
||||
public bool
|
||||
spa bool
|
||||
}{
|
||||
{"POST /_admin/api/v1/auth/login", true},
|
||||
{"POST /_admin/api/v1/auth/refresh", true},
|
||||
{"POST /_admin/api/v1/auth/logout", false},
|
||||
{"GET /_admin/api/v1/auth/me", false},
|
||||
{"GET /_admin/api/v1/navigation", false},
|
||||
{"GET /_admin/api/v1/settings", false},
|
||||
{"GET /_admin/api/v1/settings/{code}/schema", false},
|
||||
{"GET /_admin/api/v1/settings/{code}", false},
|
||||
{"PUT /_admin/api/v1/settings/{code}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
|
||||
{"PUT /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
|
||||
{"DELETE /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}", false},
|
||||
{"GET /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false},
|
||||
{"POST /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false},
|
||||
{"POST /auth/login", true, false},
|
||||
{"POST /auth/refresh", true, false},
|
||||
{"POST /auth/logout", false, false},
|
||||
{"GET /auth/me", false, false},
|
||||
{"GET /navigation", false, false},
|
||||
{"GET /settings", false, false},
|
||||
{"GET /settings/{code}/schema", false, false},
|
||||
{"GET /settings/{code}", false, false},
|
||||
{"PUT /settings/{code}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/list", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/form", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/schema/relation/{name}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}/bulk-delete", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}", false, false},
|
||||
{"PUT /{vendor}/{plugin}/{controller}/{id}", false, false},
|
||||
{"DELETE /{vendor}/{plugin}/{controller}/{id}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}", false, false},
|
||||
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", false, false},
|
||||
{"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink", false, false},
|
||||
{"GET ", true, true},
|
||||
{"GET /{path...}", true, true},
|
||||
}
|
||||
|
||||
// mountedKey is the full mounted route key for an inventory entry.
|
||||
func mountedKey(key string, spa bool) string {
|
||||
method, rel, _ := strings.Cut(key, " ")
|
||||
if spa {
|
||||
return method + " " + DefaultAdminPrefix + rel
|
||||
}
|
||||
return method + " " + adminAPI(rel)
|
||||
}
|
||||
|
||||
func TestPhase09PermissionMatrix(t *testing.T) {
|
||||
@@ -57,9 +71,10 @@ func TestPhase09PermissionMatrix(t *testing.T) {
|
||||
t.Fatalf("mounted %d admin routes, want %d: %#v", len(got), len(phase09Routes), router.routes)
|
||||
}
|
||||
for _, route := range phase09Routes {
|
||||
mw, ok := got[route.key]
|
||||
key := mountedKey(route.key, route.spa)
|
||||
mw, ok := got[key]
|
||||
if !ok {
|
||||
t.Fatalf("missing mounted route %s", route.key)
|
||||
t.Fatalf("missing mounted route %s in %v", key, router.routes)
|
||||
}
|
||||
hasBackend := false
|
||||
for _, name := range mw {
|
||||
@@ -257,7 +272,7 @@ func phase09DeniedService() *service {
|
||||
}
|
||||
|
||||
func phase09Request(principal *bouncer.Principal) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/_admin/api/v1/acme/demo/widgets/1/relations/editors/link", strings.NewReader(`{}`))
|
||||
req := httptest.NewRequest(http.MethodPost, adminAPI("/acme/demo/widgets/1/relations/editors/link"), strings.NewReader(`{}`))
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "widgets")
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestSecretRedaction(t *testing.T) {
|
||||
}
|
||||
|
||||
func controllerRequest(principal *bouncer.Principal) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/_admin/api/v1/acme/demo/widgets", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/widgets"), nil)
|
||||
req.SetPathValue("vendor", "acme")
|
||||
req.SetPathValue("plugin", "demo")
|
||||
req.SetPathValue("controller", "widgets")
|
||||
|
||||
3
go.mod
3
go.mod
@@ -2,6 +2,9 @@ module git.golem15.com/golem15/summercms
|
||||
|
||||
go 1.27.0
|
||||
|
||||
// The admin SPA's npm tree may ship .go files; keep ./... out of it.
|
||||
ignore ./admin/node_modules
|
||||
|
||||
require (
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
|
||||
380
internal/tools/swagger2openapi/main.go
Normal file
380
internal/tools/swagger2openapi/main.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// Command swagger2openapi converts swag v1's Swagger 2.0 JSON to OpenAPI 3.0
|
||||
// so openapi-typescript 7.x can consume it. swag v1 has no OpenAPI 3 emitter
|
||||
// (v2 is RC and rejected by STACK.md). It is a copy of the app repository's
|
||||
// converter plus a rewrite of cabana's opaque JSON types into unions, used by
|
||||
// scripts/check-admin-openapi.sh to build admin/openapi/admin.json (D-15).
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 2 {
|
||||
fmt.Fprintf(os.Stderr, "usage: swagger2openapi <swagger.json>\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
raw, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
out := swagger2openapi(doc)
|
||||
enc, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
enc = append(enc, '\n')
|
||||
if _, err := os.Stdout.Write(enc); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func swagger2openapi(doc map[string]any) map[string]any {
|
||||
out := map[string]any{
|
||||
"openapi": "3.0.3",
|
||||
}
|
||||
if info, ok := doc["info"]; ok {
|
||||
out["info"] = info
|
||||
}
|
||||
if tags, ok := doc["tags"]; ok {
|
||||
out["tags"] = tags
|
||||
}
|
||||
if servers := convertServers(doc); len(servers) > 0 {
|
||||
out["servers"] = servers
|
||||
}
|
||||
if paths, ok := doc["paths"].(map[string]any); ok {
|
||||
out["paths"] = convertPaths(paths)
|
||||
}
|
||||
components := map[string]any{}
|
||||
if defs, ok := doc["definitions"].(map[string]any); ok {
|
||||
rewriteOpaque(defs)
|
||||
components["schemas"] = defs
|
||||
}
|
||||
if sec, ok := doc["securityDefinitions"].(map[string]any); ok {
|
||||
components["securitySchemes"] = convertSecurity(sec)
|
||||
}
|
||||
if len(components) > 0 {
|
||||
out["components"] = components
|
||||
}
|
||||
if sec, ok := doc["security"]; ok {
|
||||
out["security"] = sec
|
||||
}
|
||||
return rewriteRefs(out).(map[string]any)
|
||||
}
|
||||
|
||||
func convertServers(doc map[string]any) []any {
|
||||
host, _ := doc["host"].(string)
|
||||
base, _ := doc["basePath"].(string)
|
||||
schemes, _ := doc["schemes"].([]any)
|
||||
if host == "" && (base == "" || base == "/") && len(schemes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(schemes) == 0 {
|
||||
schemes = []any{"https"}
|
||||
}
|
||||
if base == "" {
|
||||
base = "/"
|
||||
}
|
||||
out := make([]any, 0, len(schemes))
|
||||
for _, s := range schemes {
|
||||
scheme, _ := s.(string)
|
||||
url := scheme + "://" + host
|
||||
if host == "" {
|
||||
url = base
|
||||
} else if base != "/" {
|
||||
url += base
|
||||
}
|
||||
out = append(out, map[string]any{"url": url})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertPaths(paths map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(paths))
|
||||
for path, raw := range paths {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
out[path] = raw
|
||||
continue
|
||||
}
|
||||
converted := make(map[string]any, len(item))
|
||||
var produces []string
|
||||
if p, ok := item["produces"].([]any); ok {
|
||||
produces = stringList(p)
|
||||
}
|
||||
var consumes []string
|
||||
if c, ok := item["consumes"].([]any); ok {
|
||||
consumes = stringList(c)
|
||||
}
|
||||
for k, v := range item {
|
||||
switch k {
|
||||
case "produces", "consumes":
|
||||
continue
|
||||
case "get", "put", "post", "delete", "options", "head", "patch", "trace":
|
||||
op, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
converted[k] = v
|
||||
continue
|
||||
}
|
||||
converted[k] = convertOperation(op, produces, consumes)
|
||||
default:
|
||||
converted[k] = v
|
||||
}
|
||||
}
|
||||
out[path] = converted
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertOperation(op map[string]any, parentProduces, parentConsumes []string) map[string]any {
|
||||
out := make(map[string]any, len(op))
|
||||
produces := parentProduces
|
||||
if p, ok := op["produces"].([]any); ok {
|
||||
produces = stringList(p)
|
||||
}
|
||||
if len(produces) == 0 {
|
||||
produces = []string{"application/json"}
|
||||
}
|
||||
consumes := parentConsumes
|
||||
if c, ok := op["consumes"].([]any); ok {
|
||||
consumes = stringList(c)
|
||||
}
|
||||
if len(consumes) == 0 {
|
||||
consumes = []string{"application/json"}
|
||||
}
|
||||
for k, v := range op {
|
||||
switch k {
|
||||
case "produces", "consumes":
|
||||
continue
|
||||
case "parameters":
|
||||
params, body := splitParameters(v, consumes)
|
||||
if len(params) > 0 {
|
||||
out["parameters"] = params
|
||||
}
|
||||
if body != nil {
|
||||
out["requestBody"] = body
|
||||
}
|
||||
case "responses":
|
||||
res, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
out[k] = v
|
||||
continue
|
||||
}
|
||||
out[k] = convertResponses(res, produces)
|
||||
default:
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitParameters(v any, consumes []string) (params []any, body map[string]any) {
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
for _, item := range list {
|
||||
p, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
params = append(params, item)
|
||||
continue
|
||||
}
|
||||
if in, _ := p["in"].(string); in == "body" {
|
||||
content := map[string]any{}
|
||||
for _, ct := range consumes {
|
||||
entry := map[string]any{}
|
||||
if schema, ok := p["schema"]; ok {
|
||||
entry["schema"] = schema
|
||||
}
|
||||
content[ct] = entry
|
||||
}
|
||||
body = map[string]any{"content": content}
|
||||
if req, ok := p["required"]; ok {
|
||||
body["required"] = req
|
||||
}
|
||||
if desc, ok := p["description"]; ok {
|
||||
body["description"] = desc
|
||||
}
|
||||
continue
|
||||
}
|
||||
params = append(params, convertParameter(p))
|
||||
}
|
||||
return params, body
|
||||
}
|
||||
|
||||
var paramSchemaKeys = map[string]struct{}{
|
||||
"type": {}, "format": {}, "items": {}, "enum": {}, "default": {},
|
||||
"minimum": {}, "maximum": {}, "minLength": {}, "maxLength": {},
|
||||
"pattern": {}, "uniqueItems": {}, "multipleOf": {},
|
||||
"exclusiveMinimum": {}, "exclusiveMaximum": {},
|
||||
"additionalProperties": {}, "properties": {},
|
||||
}
|
||||
|
||||
func convertParameter(p map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(p))
|
||||
schema := map[string]any{}
|
||||
for k, v := range p {
|
||||
if _, ok := paramSchemaKeys[k]; ok {
|
||||
schema[k] = v
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if len(schema) > 0 {
|
||||
out["schema"] = schema
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertResponses(res map[string]any, produces []string) map[string]any {
|
||||
out := make(map[string]any, len(res))
|
||||
for code, raw := range res {
|
||||
r, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
out[code] = raw
|
||||
continue
|
||||
}
|
||||
converted := make(map[string]any, len(r))
|
||||
var schema any
|
||||
for k, v := range r {
|
||||
if k == "schema" {
|
||||
schema = v
|
||||
continue
|
||||
}
|
||||
converted[k] = v
|
||||
}
|
||||
if schema != nil {
|
||||
content := map[string]any{}
|
||||
for _, ct := range produces {
|
||||
content[ct] = map[string]any{"schema": schema}
|
||||
}
|
||||
converted["content"] = content
|
||||
}
|
||||
out[code] = converted
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertSecurity(sec map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(sec))
|
||||
for name, raw := range sec {
|
||||
s, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
out[name] = raw
|
||||
continue
|
||||
}
|
||||
copied := make(map[string]any, len(s))
|
||||
for k, v := range s {
|
||||
copied[k] = v
|
||||
}
|
||||
if t, _ := copied["type"].(string); t == "oauth2" {
|
||||
flows := map[string]any{}
|
||||
flow, _ := copied["flow"].(string)
|
||||
delete(copied, "flow")
|
||||
flowObj := map[string]any{}
|
||||
if u, ok := copied["authorizationUrl"]; ok {
|
||||
flowObj["authorizationUrl"] = u
|
||||
delete(copied, "authorizationUrl")
|
||||
}
|
||||
if u, ok := copied["tokenUrl"]; ok {
|
||||
flowObj["tokenUrl"] = u
|
||||
delete(copied, "tokenUrl")
|
||||
}
|
||||
if sc, ok := copied["scopes"]; ok {
|
||||
flowObj["scopes"] = sc
|
||||
delete(copied, "scopes")
|
||||
}
|
||||
switch flow {
|
||||
case "implicit":
|
||||
flows["implicit"] = flowObj
|
||||
case "password":
|
||||
flows["password"] = flowObj
|
||||
case "application":
|
||||
flows["clientCredentials"] = flowObj
|
||||
case "accessCode":
|
||||
flows["authorizationCode"] = flowObj
|
||||
}
|
||||
copied["flows"] = flows
|
||||
}
|
||||
out[name] = copied
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func rewriteRefs(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
if k == "$ref" {
|
||||
if s, ok := val.(string); ok {
|
||||
const from = "#/definitions/"
|
||||
const to = "#/components/schemas/"
|
||||
if len(s) >= len(from) && s[:len(from)] == from {
|
||||
out[k] = to + s[len(from):]
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out[k] = rewriteRefs(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, val := range t {
|
||||
out[i] = rewriteRefs(val)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func stringList(in []any) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
s, ok := v.(string)
|
||||
if ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// opaqueUnions replaces definitions that swag can only see as an empty object
|
||||
// because their Go type marshals itself. cabana.jsonScalar is a string,
|
||||
// number, boolean or null; cabana.fieldContext is a string or string list.
|
||||
var opaqueUnions = map[string]map[string]any{
|
||||
"cabana.jsonScalar": {
|
||||
"nullable": true,
|
||||
"oneOf": []any{
|
||||
map[string]any{"type": "string"},
|
||||
map[string]any{"type": "number"},
|
||||
map[string]any{"type": "boolean"},
|
||||
},
|
||||
},
|
||||
"cabana.fieldContext": {
|
||||
"oneOf": []any{
|
||||
map[string]any{"type": "string"},
|
||||
map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func rewriteOpaque(defs map[string]any) {
|
||||
for name, union := range opaqueUnions {
|
||||
if _, ok := defs[name]; ok {
|
||||
defs[name] = union
|
||||
}
|
||||
}
|
||||
}
|
||||
60
scripts/check-admin-openapi.sh
Executable file
60
scripts/check-admin-openapi.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate the framework admin OpenAPI document and the admin SPA's TypeScript
|
||||
# types from the swag annotations in cabana (D-15):
|
||||
# swag v1.16.6 (Swagger 2.0) -> internal/tools/swagger2openapi (OpenAPI 3.0)
|
||||
# -> openapi-typescript (admin devDependency) -> admin/src/api/schema.d.ts
|
||||
# Without arguments the outputs replace admin/openapi/admin.json and
|
||||
# admin/src/api/schema.d.ts. With --check nothing is written and the script
|
||||
# exits non-zero when either committed file differs from a fresh generation.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
CHECK=0
|
||||
case "${1:-}" in
|
||||
"") ;;
|
||||
--check) CHECK=1 ;;
|
||||
*)
|
||||
echo "usage: scripts/check-admin-openapi.sh [--check]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ! -d admin/node_modules ]]; then
|
||||
npm --prefix admin ci
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
go run github.com/swaggo/swag/cmd/swag@v1.16.6 init \
|
||||
--dir cabana \
|
||||
--generalInfo admin_openapi.go \
|
||||
--output "$TMP" \
|
||||
--outputTypes json \
|
||||
--requiredByDefault \
|
||||
--quiet
|
||||
|
||||
if [[ ! -s "$TMP/swagger.json" ]]; then
|
||||
echo "check-admin-openapi: swag did not generate swagger.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
go run ./internal/tools/swagger2openapi "$TMP/swagger.json" > "$TMP/admin.json"
|
||||
admin/node_modules/.bin/openapi-typescript "$TMP/admin.json" -o "$TMP/schema.d.ts"
|
||||
|
||||
if [[ "$CHECK" -eq 1 ]]; then
|
||||
status=0
|
||||
diff -u admin/openapi/admin.json "$TMP/admin.json" || status=1
|
||||
diff -u admin/src/api/schema.d.ts "$TMP/schema.d.ts" || status=1
|
||||
if [[ "$status" -ne 0 ]]; then
|
||||
echo "check-admin-openapi: committed admin OpenAPI output is stale; run scripts/check-admin-openapi.sh" >&2
|
||||
fi
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
mkdir -p admin/openapi admin/src/api
|
||||
cp "$TMP/admin.json" admin/openapi/admin.json
|
||||
cp "$TMP/schema.d.ts" admin/src/api/schema.d.ts
|
||||
echo "check-admin-openapi: wrote admin/openapi/admin.json and admin/src/api/schema.d.ts"
|
||||
Reference in New Issue
Block a user