diff --git a/apps/admin/src/app/(authenticated)/_components/ImpersonationBanner.tsx b/apps/admin/src/app/(authenticated)/_components/ImpersonationBanner.tsx new file mode 100644 index 00000000..b90ee0ee --- /dev/null +++ b/apps/admin/src/app/(authenticated)/_components/ImpersonationBanner.tsx @@ -0,0 +1,75 @@ +'use client'; + +/** + * ImpersonationBanner — fixed bar at the very top of the admin shell + * that appears when the current session is an impersonation. + * + * On mount the component GETs /api/v1/auth/impersonation; if the + * response carries `impersonation: true` it renders a yellow warning + * bar reading "Signed in as on behalf of . Exit". + * Clicking Exit hits DELETE /api/v1/auth/impersonation and reloads + * the page so the rest of the chrome re-renders against the actor's + * restored session. + * + * The banner deliberately fails closed: if the API is unreachable or + * returns an error, no banner is rendered. The cost of a missed + * impersonation indicator (the operator forgets they're impersonating) + * is annoying but reversible; the cost of a false-positive (banner + * appears on a normal session) is more confusing. + */ +import { useEffect, useState, type ReactElement } from 'react'; +import { api } from '@/lib/api-client'; + +interface WhoamiResponse { + impersonation: boolean; + actor_user_id?: string; + target_user_id?: string; +} + +export function ImpersonationBanner(): ReactElement | null { + const [state, setState] = useState(null); + const [exiting, setExiting] = useState(false); + + useEffect(() => { + let cancelled = false; + api + .get('/api/v1/auth/impersonation') + .then((data) => { + if (!cancelled) setState(data); + }) + .catch(() => { + // Silent: see file header. + }); + return () => { + cancelled = true; + }; + }, []); + + if (!state || !state.impersonation) return null; + + const onExit = async () => { + setExiting(true); + try { + await api.delete('/api/v1/auth/impersonation'); + window.location.assign('/'); + } catch { + setExiting(false); + } + }; + + return ( +
+ + Signed in as {state.target_user_id} on behalf of{' '} + {state.actor_user_id}. + + +
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/_components/PluginSidebarSection.tsx b/apps/admin/src/app/(authenticated)/_components/PluginSidebarSection.tsx new file mode 100644 index 00000000..0fe6b4c9 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/_components/PluginSidebarSection.tsx @@ -0,0 +1,99 @@ +'use client'; + +/** + * PluginSidebarSection — admin sidebar entries contributed by active + * plugins. Issue #228. + * + * On mount the component fetches /api/v1/admin/plugin-pages, then + * renders one sidebar link per declared page under a "Plugins" + * section header. The router target is /plugins/{plugin}/{slug}; the + * plugin frontend host is responsible for what loads there. + * + * Why a separate component (rather than the static NAV_SECTIONS + * array in Sidebar.tsx)? Because the plugin set changes at runtime + * — activating a plugin should surface its admin pages on the next + * navigation without a redeploy. The component lazy-imports the + * page-resolver bridge from the plugin frontend host, so the bundle + * stays slim when no plugin is active. + */ +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useEffect, useState, type ReactElement } from 'react'; +import { Plug } from 'lucide-react'; + +import { api } from '@/lib/api-client'; + +interface PluginPage { + plugin: string; + slug: string; + label: string; + icon?: string; + capability?: string; +} + +interface PluginPagesResponse { + pages: PluginPage[]; +} + +interface Props { + /** Capabilities the current viewer holds. A page with a declared + * capability is hidden unless the viewer carries it. */ + viewerCapabilities?: ReadonlySet; +} + +export function PluginSidebarSection({ + viewerCapabilities, +}: Props): ReactElement | null { + const pathname = usePathname() ?? '/'; + const [pages, setPages] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .get('/api/v1/admin/plugin-pages') + .then((data) => { + if (!cancelled) setPages(data.pages ?? []); + }) + .catch(() => { + if (!cancelled) setPages([]); + }); + return () => { + cancelled = true; + }; + }, []); + + if (!pages || pages.length === 0) return null; + + const visible = pages.filter((p) => { + if (!p.capability) return true; + if (!viewerCapabilities) return true; // fail-open in dev; sidebar isn't security-critical. + return viewerCapabilities.has(p.capability); + }); + + if (visible.length === 0) return null; + + return ( +
+
Plugins
+
    + {visible.map((p) => { + const href = `/plugins/${encodeURIComponent(p.plugin)}/${encodeURIComponent(p.slug)}`; + const active = pathname === href || pathname.startsWith(`${href}/`); + return ( +
  • + + + {p.label} + +
  • + ); + })} +
+
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx b/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx index a2f84252..a3d4b59e 100644 --- a/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx +++ b/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx @@ -61,6 +61,7 @@ import { Users, } from 'lucide-react'; import { GlobalSearch } from '../../../components/GlobalSearch'; +import { PluginSidebarSection } from './PluginSidebarSection'; type LucideIcon = ComponentType>; @@ -236,6 +237,10 @@ export function Sidebar(): ReactElement { ))} + {/* Dynamic Plugins section — fetched at runtime from + /api/v1/admin/plugin-pages so activating a plugin lights + up its sidebar entries without a redeploy. Issue #228. */} + {!collapsed && } {/* Upgrade card — radial-glow forest-2 surface, emerald CTA. */} diff --git a/apps/admin/src/app/(authenticated)/appearance/menus/MenusClient.tsx b/apps/admin/src/app/(authenticated)/appearance/menus/MenusClient.tsx new file mode 100644 index 00000000..9813c86a --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/menus/MenusClient.tsx @@ -0,0 +1,274 @@ +'use client'; + +/** + * Navigation menus admin client — issue #54. + * + * Two-pane layout: the left rail lists menus and offers a "New menu" + * form; the right pane shows the selected menu's items in drag-to- + * reorder order. Items can be edited inline (label/url) or removed; + * dragging an item up or down rewrites its `path` and the local order + * optimistically, then PATCHes the new ordering via + * POST /api/v1/admin/menus/{id}/items/reorder. + * + * The drag-and-drop uses native HTML5 drag events — keeps the bundle + * lean and avoids pulling in @dnd-kit just for this surface. + */ +import { + useCallback, + useEffect, + useMemo, + useState, + type DragEvent, + type FormEvent, + type ReactElement, +} from 'react'; +import { api, ApiError } from '@/lib/api-client'; +import type { Menu, MenuItem, MenuWithItems } from './types'; + +interface Props { + initialMenus: Menu[]; +} + +export function MenusClient({ initialMenus }: Props): ReactElement { + const [menus, setMenus] = useState(initialMenus); + const [selectedId, setSelectedId] = useState(initialMenus[0]?.id ?? ''); + const [items, setItems] = useState([]); + const [loadingItems, setLoadingItems] = useState(false); + const [error, setError] = useState(''); + + // Load the items for the selected menu whenever it changes. + useEffect(() => { + if (!selectedId) { + setItems([]); + return; + } + let cancelled = false; + setLoadingItems(true); + setError(''); + api + .get(`/api/v1/admin/menus/${selectedId}`) + .then((data) => { + if (cancelled) return; + setItems(data.items ?? []); + }) + .catch((e: unknown) => { + if (cancelled) return; + setError(e instanceof ApiError ? `Load failed (${e.status})` : 'Load failed'); + }) + .finally(() => { + if (!cancelled) setLoadingItems(false); + }); + return () => { + cancelled = true; + }; + }, [selectedId]); + + const sortedItems = useMemo( + () => [...items].sort((a, b) => a.path.localeCompare(b.path)), + [items], + ); + + const onCreateMenu = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const slug = (form.elements.namedItem('slug') as HTMLInputElement).value.trim(); + const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim(); + if (!slug || !name) return; + try { + const created = await api.post('/api/v1/admin/menus', { slug, name }); + setMenus((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name))); + setSelectedId(created.id); + form.reset(); + } catch (err) { + setError(err instanceof ApiError ? `Create failed (${err.status})` : 'Create failed'); + } + }, + [], + ); + + const onAddItem = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const label = (form.elements.namedItem('label') as HTMLInputElement).value.trim(); + const url = (form.elements.namedItem('url') as HTMLInputElement).value.trim(); + if (!label) return; + // Path is the next root slot. + const nextIdx = sortedItems.filter((it) => !it.path.includes('.')).length + 1; + const path = String(nextIdx).padStart(3, '0'); + try { + const created = await api.post( + `/api/v1/admin/menus/${selectedId}/items`, + { path, label, url }, + ); + setItems((prev) => [...prev, created]); + form.reset(); + } catch (err) { + setError(err instanceof ApiError ? `Add failed (${err.status})` : 'Add failed'); + } + }, + [selectedId, sortedItems], + ); + + const onDeleteItem = useCallback( + async (itemId: string) => { + try { + await api.delete(`/api/v1/admin/menus/${selectedId}/items/${itemId}`); + setItems((prev) => prev.filter((it) => it.id !== itemId)); + } catch (err) { + setError(err instanceof ApiError ? `Delete failed (${err.status})` : 'Delete failed'); + } + }, + [selectedId], + ); + + // Drag state: which item is being dragged. + const [dragId, setDragId] = useState(''); + + const onDragStart = useCallback( + (id: string) => (e: DragEvent) => { + setDragId(id); + e.dataTransfer.effectAllowed = 'move'; + }, + [], + ); + + const onDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }, []); + + const onDrop = useCallback( + (targetId: string) => async (e: DragEvent) => { + e.preventDefault(); + if (!dragId || dragId === targetId) { + setDragId(''); + return; + } + const ordered = [...sortedItems]; + const fromIdx = ordered.findIndex((it) => it.id === dragId); + const toIdx = ordered.findIndex((it) => it.id === targetId); + if (fromIdx < 0 || toIdx < 0) { + setDragId(''); + return; + } + const [moved] = ordered.splice(fromIdx, 1); + ordered.splice(toIdx, 0, moved); + // Re-stamp paths as flat root-level slots — the drag-drop surface + // here only supports a single nesting level for now. + const renumbered = ordered.map((it, i) => ({ + ...it, + path: String(i + 1).padStart(3, '0'), + })); + setItems(renumbered); + setDragId(''); + try { + await api.post(`/api/v1/admin/menus/${selectedId}/items/reorder`, { + items: renumbered.map((it) => ({ id: it.id, path: it.path })), + }); + } catch (err) { + setError(err instanceof ApiError ? `Reorder failed (${err.status})` : 'Reorder failed'); + } + }, + [dragId, selectedId, sortedItems], + ); + + return ( +
+

Navigation menus

+ {error && ( +
+ {error} +
+ )} + +
+ {/* Left rail: menus list + new-menu form. */} + + + {/* Right pane: items for the selected menu. */} +
+ {!selectedId &&

Select a menu to edit its items.

} + {selectedId && ( + <> +

Items

+ {loadingItems &&

Loading…

} +
    + {sortedItems.map((it) => ( +
  1. + + ⋮⋮ + + {it.label} + {it.url || '(no url)'} + +
  2. + ))} +
+
+

Add item

+ + + +
+ + )} +
+
+
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/appearance/menus/page.tsx b/apps/admin/src/app/(authenticated)/appearance/menus/page.tsx new file mode 100644 index 00000000..f4831458 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/menus/page.tsx @@ -0,0 +1,47 @@ +/** + * Navigation menus admin — issue #54. + * + * Server entry that delegates to for the live UX. The + * server component does the initial GET against the admin API so the + * first paint already carries the menus list; the client component + * owns create / select / item-level CRUD with drag-to-reorder. + */ +import type { ReactElement } from 'react'; +import { cookies } from 'next/headers'; +import { apiBaseUrl } from '@/lib/api-client'; +import { MenusClient } from './MenusClient'; +import type { MenuListResponse } from './types'; + +export const dynamic = 'force-dynamic'; + +async function fetchInitial(): Promise { + let cookieHeader = ''; + try { + const store = await cookies(); + cookieHeader = store + .getAll() + .map((c) => `${c.name}=${c.value}`) + .join('; '); + } catch { + cookieHeader = ''; + } + try { + const res = await fetch(`${apiBaseUrl}/api/v1/admin/menus`, { + method: 'GET', + headers: { + Accept: 'application/json', + ...(cookieHeader ? { Cookie: cookieHeader } : {}), + }, + cache: 'no-store', + }); + if (!res.ok) return null; + return (await res.json()) as MenuListResponse; + } catch { + return null; + } +} + +export default async function MenusPage(): Promise { + const data = await fetchInitial(); + return ; +} diff --git a/apps/admin/src/app/(authenticated)/appearance/menus/types.ts b/apps/admin/src/app/(authenticated)/appearance/menus/types.ts new file mode 100644 index 00000000..e6bab6bb --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/menus/types.ts @@ -0,0 +1,40 @@ +/** + * Wire types for the navigation-menus admin surface. Mirrors the JSON + * shapes returned by /api/v1/admin/menus. + */ +export interface Menu { + id: string; + slug: string; + name: string; + attrs?: Record; + created_at: string; + updated_at: string; +} + +export interface MenuItem { + id: string; + menu_id: string; + /** + * Dot-separated ltree-style ordering token. Examples: + * "001" — first root item + * "001.001" — first child of "001" + * Sort lexicographically to get parents before children. + */ + path: string; + label: string; + url: string; + object_type?: 'post' | 'page' | 'term' | 'custom'; + object_id?: string; + attrs?: Record; + created_at: string; + updated_at: string; +} + +export interface MenuListResponse { + menus: Menu[]; +} + +export interface MenuWithItems { + menu: Menu; + items: MenuItem[]; +} diff --git a/apps/admin/src/app/(authenticated)/layout.tsx b/apps/admin/src/app/(authenticated)/layout.tsx index 2bfa934a..2edeb375 100644 --- a/apps/admin/src/app/(authenticated)/layout.tsx +++ b/apps/admin/src/app/(authenticated)/layout.tsx @@ -20,6 +20,7 @@ * brand's signature serif-italic accent. */ import type { ReactElement, ReactNode } from 'react'; +import { ImpersonationBanner } from './_components/ImpersonationBanner'; import { Sidebar } from './_components/Sidebar'; import { TopHeader } from './_components/TopHeader'; @@ -30,6 +31,7 @@ export default function AuthenticatedLayout({ }): ReactElement { return (
+
diff --git a/apps/admin/src/app/(authenticated)/plugins/[plugin]/[slug]/PluginPageBridge.tsx b/apps/admin/src/app/(authenticated)/plugins/[plugin]/[slug]/PluginPageBridge.tsx new file mode 100644 index 00000000..d793e23b --- /dev/null +++ b/apps/admin/src/app/(authenticated)/plugins/[plugin]/[slug]/PluginPageBridge.tsx @@ -0,0 +1,86 @@ +'use client'; + +/** + * PluginPageBridge — client bridge that lazy-loads the plugin frontend + * host and asks it for the (plugin, slug) page module. + * + * The host module is expected to expose a `resolveAdminPage(plugin, + * slug)` function returning either a React component or null. When the + * host module isn't bundled (development without active plugins), the + * bridge falls back to a static "no module registered" placeholder so + * the route still renders something useful. + * + * Why dynamic import: keeps the admin bundle slim when no plugin is + * active; the host module pulls in WASM glue + plugin-side React + * runtimes that we don't want in every admin payload. + */ +import { useEffect, useState, type ReactElement, type ComponentType } from 'react'; + +interface Props { + plugin: string; + slug: string; +} + +interface PluginPageHost { + resolveAdminPage: ( + plugin: string, + slug: string, + ) => ComponentType<{ plugin: string; slug: string }> | null; +} + +type LoadState = + | { kind: 'loading' } + | { kind: 'ready'; Page: ComponentType<{ plugin: string; slug: string }> } + | { kind: 'missing' }; + +export function PluginPageBridge({ plugin, slug }: Props): ReactElement { + const [state, setState] = useState({ kind: 'loading' }); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + // Dynamic import so the bundle stays slim when the host is + // absent. The path resolves to the plugin frontend host + // entry; production builds inject the real module via a path + // alias. + const host = (await import( + /* webpackIgnore: true */ '@gonext/plugin-frontend-host' as string + ).catch(() => null)) as PluginPageHost | null; + if (cancelled) return; + if (!host || typeof host.resolveAdminPage !== 'function') { + setState({ kind: 'missing' }); + return; + } + const Page = host.resolveAdminPage(plugin, slug); + if (!Page) { + setState({ kind: 'missing' }); + return; + } + setState({ kind: 'ready', Page }); + } catch { + if (!cancelled) setState({ kind: 'missing' }); + } + })(); + return () => { + cancelled = true; + }; + }, [plugin, slug]); + + if (state.kind === 'loading') return

Loading plugin page…

; + if (state.kind === 'missing') { + return ( +
+

No module registered

+

+ The plugin {plugin} declared an admin page{' '} + {slug} in its manifest, but no frontend module is + registered for it. The plugin host bundle may not be loaded in + this environment. +

+
+ ); + } + const { Page } = state; + return ; +} diff --git a/apps/admin/src/app/(authenticated)/plugins/[plugin]/[slug]/page.tsx b/apps/admin/src/app/(authenticated)/plugins/[plugin]/[slug]/page.tsx new file mode 100644 index 00000000..f14af35e --- /dev/null +++ b/apps/admin/src/app/(authenticated)/plugins/[plugin]/[slug]/page.tsx @@ -0,0 +1,28 @@ +/** + * Plugin admin page host — issue #228. + * + * Catch-all route at /plugins/{plugin}/{slug}. The actual page module + * is provided by the plugin's frontend bundle; this route is a thin + * shell that lazy-imports the plugin host's resolver and hands it the + * (plugin, slug) tuple. If no plugin module is registered for the + * tuple, the host renders a "not found" placeholder. + * + * The plugin frontend host module (@gonext/plugin-frontend-host) is + * not yet on the workspace dependency graph; the import below is a + * dynamic specifier so a build-time absence falls back to the inline + * placeholder. Production deployments swap the placeholder out by + * shipping the host module on the admin's path alias. + */ +import type { ReactElement } from 'react'; +import { PluginPageBridge } from './PluginPageBridge'; + +interface Props { + params: Promise<{ plugin: string; slug: string }>; +} + +export const dynamic = 'force-dynamic'; + +export default async function PluginAdminPage({ params }: Props): Promise { + const { plugin, slug } = await params; + return ; +} diff --git a/apps/admin/src/app/(authenticated)/settings/privacy/PrivacyForm.tsx b/apps/admin/src/app/(authenticated)/settings/privacy/PrivacyForm.tsx new file mode 100644 index 00000000..30b2c5b6 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/settings/privacy/PrivacyForm.tsx @@ -0,0 +1,33 @@ +'use client'; + +/** + * PrivacyForm — client wrapper that wires the generic SettingsForm to + * the registry's PATCH endpoint. The schema lives in ./schema.ts. + */ +import type { ReactElement } from 'react'; +import { SettingsForm } from '../SettingsForm'; +import { patchSettings } from '../api'; +import type { SettingsSection, SettingsValues } from '../types'; +import { PRIVACY_SCHEMA } from './schema'; + +export interface PrivacyFormProps { + initialValues: SettingsValues; + banner?: string; + sections?: readonly SettingsSection[]; +} + +export function PrivacyForm({ + initialValues, + banner, + sections, +}: PrivacyFormProps): ReactElement { + return ( + + ); +} diff --git a/apps/admin/src/app/(authenticated)/settings/privacy/page.tsx b/apps/admin/src/app/(authenticated)/settings/privacy/page.tsx index fa16d591..497d93ad 100644 --- a/apps/admin/src/app/(authenticated)/settings/privacy/page.tsx +++ b/apps/admin/src/app/(authenticated)/settings/privacy/page.tsx @@ -1,65 +1,76 @@ /** - * /settings/privacy — GDPR self-service surface (issue #216). + * Privacy settings — issue #225. * - * Two actions, both irreversible to varying degrees: + * Fetches the current privacy-group values from the registry on the + * server so the form pre-fills on first paint. Mirrors the general / + * reading / writing pages structurally. * - * 1. Download your data — kicks off an async export job. The - * worker assembles a ZIP and returns a download URL through the - * poll endpoint; this page surfaces the job id and shows a - * banner with the polling URL. - * - * 2. Delete account — anonymises the user in place and schedules a - * hard-delete 30 days out. Requires the current password (typed - * twice) so an accidental click can't destroy data. After a - * successful delete the API also invalidates every session, so - * the next page navigation kicks the user back to the login - * screen. - * - * Styled against the Living-Systems brand: cream paper, Archivo - * headline with the italic accent, emerald CTA for export, red - * destructive CTA for delete. See docs/design/HANDOFF.md. - * - * The client component is deliberately small — the heavy lifting - * happens on the server. We do NOT pre-fetch any data on this page - * because both actions are write-only. + * The GDPR self-service toggle on this page gates the public + * /api/v1/account/data/export endpoint — flipping it off makes the + * endpoint return 403 and the user-facing affordance disappear. */ import type { ReactElement } from 'react'; import Link from 'next/link'; -import { ArrowLeft, ShieldCheck } from 'lucide-react'; import { Headline } from '@/components/ui/headline'; -import { PrivacyActions } from './components/PrivacyActions'; +import { fetchSettings } from '../api'; +import type { SettingsSection } from '../types'; +import { PrivacyForm } from './PrivacyForm'; -export default function PrivacyPage(): ReactElement { - return ( -
-
- -
+export const dynamic = 'force-dynamic'; + +const SECTIONS: readonly SettingsSection[] = [ + { + title: 'Cookie policy', + description: "What the site tells visitors about its use of cookies.", + keys: [ + 'core.privacy.cookie_policy_url', + 'core.privacy.cookie_policy_text', + ], + }, + { + title: 'Retention windows', + description: + "How long the platform keeps audit, session, and login records. Use 0 to retain indefinitely.", + keys: [ + 'core.privacy.retention.audit_days', + 'core.privacy.retention.sessions_days', + 'core.privacy.retention.login_attempts_days', + ], + }, + { + title: 'GDPR self-service', + description: + "Allow signed-in users to download a JSON archive of their personal data. Disabling this returns 403 from /api/v1/account/data/export.", + keys: ['core.privacy.allow_gdpr_self_service'], + }, +]; - +export default async function PrivacySettingsPage(): Promise { + const { values, available } = await fetchSettings('privacy'); + return ( +
+ + ← Back to settings + +
+ + Privacy settings. + +

+ Cookies, retention windows, and the GDPR self-service toggle. +

+
+
); } diff --git a/apps/admin/src/app/(authenticated)/settings/privacy/schema.ts b/apps/admin/src/app/(authenticated)/settings/privacy/schema.ts new file mode 100644 index 00000000..3ceebd3b --- /dev/null +++ b/apps/admin/src/app/(authenticated)/settings/privacy/schema.ts @@ -0,0 +1,50 @@ +/** + * Schema for the Privacy settings form — issue #225. + * + * Mirrors the keys registered by [packages/go/settings/privacy.go]. + * The settings registry endpoint at /api/v1/settings?group=privacy + * returns the same key set; the form schema is what drives the input + * controls. + */ +import type { Setting } from '../types'; + +export const PRIVACY_SCHEMA: readonly Setting[] = [ + { + key: 'core.privacy.cookie_policy_url', + label: 'Cookie policy URL', + type: 'url', + placeholder: 'https://example.com/cookies', + help: 'Linked from the cookie consent banner and the footer.', + }, + { + key: 'core.privacy.cookie_policy_text', + label: 'Cookie banner text', + type: 'text', + placeholder: 'This site uses cookies to keep you signed in.', + help: 'Plain text shown in the consent banner.', + }, + { + key: 'core.privacy.retention.audit_days', + label: 'Audit-log retention (days)', + type: 'number', + help: 'How long to keep audit entries. Use 0 to retain indefinitely.', + }, + { + key: 'core.privacy.retention.sessions_days', + label: 'Sessions retention (days)', + type: 'number', + help: 'How long to keep expired session records. 0 to retain indefinitely.', + }, + { + key: 'core.privacy.retention.login_attempts_days', + label: 'Login attempts retention (days)', + type: 'number', + help: 'How long to keep failed-login records. 0 to retain indefinitely.', + }, + { + key: 'core.privacy.allow_gdpr_self_service', + label: 'Allow GDPR self-service data export', + type: 'boolean', + help: 'When enabled, signed-in users may export their personal data via /api/v1/account/data/export.', + }, +]; diff --git a/apps/admin/src/app/(authenticated)/settings/sessions/SessionsClient.tsx b/apps/admin/src/app/(authenticated)/settings/sessions/SessionsClient.tsx new file mode 100644 index 00000000..0d72d476 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/settings/sessions/SessionsClient.tsx @@ -0,0 +1,171 @@ +'use client'; + +/** + * Active-sessions self-service client — issue #205. + * + * On mount: GET /api/v1/auth/sessions and render one row per live + * session. Each row carries a Revoke action; a top-of-list "Sign out + * of all other devices" button hits DELETE /api/v1/auth/sessions + * (which the server scopes to "everything except the current one"). + * + * Optimistic updates: a successful Revoke removes the row immediately; + * a successful "Revoke all other" removes every non-current row in a + * single state update. Errors revert the action and surface a banner. + */ +import { useCallback, useEffect, useState, type ReactElement } from 'react'; +import { api, ApiError } from '@/lib/api-client'; +import type { SessionListResponse, SessionView } from './types'; + +type LoadState = + | { kind: 'loading' } + | { kind: 'ready'; sessions: SessionView[] } + | { kind: 'error'; message: string }; + +function formatStamp(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function SessionsClient(): ReactElement { + const [state, setState] = useState({ kind: 'loading' }); + const [busy, setBusy] = useState(''); + + const reload = useCallback(async () => { + setState({ kind: 'loading' }); + try { + const data = await api.get('/api/v1/auth/sessions'); + setState({ kind: 'ready', sessions: data.sessions ?? [] }); + } catch (err) { + const message = + err instanceof ApiError ? `Load failed (${err.status})` : 'Load failed'; + setState({ kind: 'error', message }); + } + }, []); + + useEffect(() => { + void reload(); + }, [reload]); + + const onRevoke = useCallback( + async (id: string) => { + setBusy(id); + const before = state.kind === 'ready' ? state.sessions : null; + // Optimistic remove. + if (before) { + setState({ + kind: 'ready', + sessions: before.filter((s) => s.id !== id), + }); + } + try { + await api.delete(`/api/v1/auth/sessions/${id}`); + } catch (err) { + // Revert. + if (before) setState({ kind: 'ready', sessions: before }); + const message = + err instanceof ApiError ? `Revoke failed (${err.status})` : 'Revoke failed'; + setState({ kind: 'error', message }); + } finally { + setBusy(''); + } + }, + [state], + ); + + const onRevokeAllOther = useCallback(async () => { + setBusy('all'); + const before = state.kind === 'ready' ? state.sessions : null; + if (before) { + setState({ + kind: 'ready', + sessions: before.filter((s) => s.current), + }); + } + try { + await api.delete('/api/v1/auth/sessions'); + } catch (err) { + if (before) setState({ kind: 'ready', sessions: before }); + const message = + err instanceof ApiError + ? `Bulk revoke failed (${err.status})` + : 'Bulk revoke failed'; + setState({ kind: 'error', message }); + } finally { + setBusy(''); + } + }, [state]); + + return ( +
+

Active sessions

+

+ Every device you have signed in from. Revoking a session signs out the + corresponding browser immediately. +

+ + {state.kind === 'loading' &&

Loading…

} + {state.kind === 'error' && ( +
+ {state.message} + +
+ )} + + {state.kind === 'ready' && ( + <> +
+ +
+ +
    + {state.sessions.map((s) => ( +
  • +
    + {s.device_label || 'Unknown device'} + {s.current && This device} + {s.ip || 'IP unavailable'} +
    +
    + Created {formatStamp(s.created_at)} + Last seen {formatStamp(s.last_seen_at)} +
    + {!s.current && ( + + )} +
  • + ))} + {state.sessions.length === 0 && ( +
  • No active sessions.
  • + )} +
+ + )} +
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/settings/sessions/page.tsx b/apps/admin/src/app/(authenticated)/settings/sessions/page.tsx new file mode 100644 index 00000000..58f3bc3a --- /dev/null +++ b/apps/admin/src/app/(authenticated)/settings/sessions/page.tsx @@ -0,0 +1,16 @@ +/** + * Active-sessions self-service — issue #205. + * + * Lists the current user's live sessions (GET /api/v1/auth/sessions), + * lets them revoke any single session, and exposes a "Sign out of all + * other devices" bulk action. The route is gated by the authenticated + * layout — anonymous traffic gets redirected at the edge. + */ +import type { ReactElement } from 'react'; +import { SessionsClient } from './SessionsClient'; + +export const dynamic = 'force-dynamic'; + +export default function SessionsPage(): ReactElement { + return ; +} diff --git a/apps/admin/src/app/(authenticated)/settings/sessions/types.ts b/apps/admin/src/app/(authenticated)/settings/sessions/types.ts new file mode 100644 index 00000000..64832536 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/settings/sessions/types.ts @@ -0,0 +1,18 @@ +/** + * Wire types for the sessions self-service surface. Matches the JSON + * shapes returned by /api/v1/auth/sessions (issue #205). + */ +export interface SessionView { + /** Stable per-session hex identifier (truncated SHA-256 of token). */ + id: string; + created_at: string; + last_seen_at: string; + device_label: string; + ip: string; + /** True iff this session is the one carrying the current request. */ + current: boolean; +} + +export interface SessionListResponse { + sessions: SessionView[]; +} diff --git a/apps/admin/src/app/(authenticated)/settings/types.ts b/apps/admin/src/app/(authenticated)/settings/types.ts index a88908d8..909b3185 100644 --- a/apps/admin/src/app/(authenticated)/settings/types.ts +++ b/apps/admin/src/app/(authenticated)/settings/types.ts @@ -78,4 +78,5 @@ export type SettingsGroup = | 'core.site' | 'core.reading' | 'core.writing' - | 'core.permalinks'; + | 'core.permalinks' + | 'privacy'; diff --git a/apps/admin/src/app/(authenticated)/users/[id]/ImpersonateButton.tsx b/apps/admin/src/app/(authenticated)/users/[id]/ImpersonateButton.tsx new file mode 100644 index 00000000..9f1c2b7d --- /dev/null +++ b/apps/admin/src/app/(authenticated)/users/[id]/ImpersonateButton.tsx @@ -0,0 +1,71 @@ +'use client'; + +/** + * ImpersonateButton — POST /api/v1/admin/users/{id}/impersonate. + * + * Visible only when the current viewer is a super_admin and the target + * is not the viewer themselves. Successful response swaps the session + * cookie (the server Set-Cookies it inline) and reloads the page so + * the rest of the admin shell re-renders against the impersonated + * identity — including the mounted in the + * authenticated layout. + */ +import { useState, type ReactElement } from 'react'; +import { api, ApiError } from '@/lib/api-client'; + +interface Props { + targetUserId: string; + /** True when the viewer carries the super_admin role. */ + canImpersonate: boolean; + /** Hide the button when targetUserId === viewer's own user ID. */ + isSelf: boolean; +} + +interface ImpersonateResponse { + impersonated_user_id: string; + actor_user_id: string; + expires_in_seconds: number; +} + +export function ImpersonateButton({ + targetUserId, + canImpersonate, + isSelf, +}: Props): ReactElement | null { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + if (!canImpersonate || isSelf) return null; + + const onClick = async () => { + setBusy(true); + setError(''); + try { + await api.post( + `/api/v1/admin/users/${targetUserId}/impersonate`, + ); + // The cookie was rewritten by the API; the next render needs a + // fresh server-side fetch to pick up the new principal. + window.location.assign('/'); + } catch (err) { + const message = + err instanceof ApiError ? `Impersonate failed (${err.status})` : 'Impersonate failed'; + setError(message); + } finally { + setBusy(false); + } + }; + + return ( + <> + + {error && ( + + {error} + + )} + + ); +} diff --git a/apps/api/internal/account/export.go b/apps/api/internal/account/export.go new file mode 100644 index 00000000..f08d96bd --- /dev/null +++ b/apps/api/internal/account/export.go @@ -0,0 +1,146 @@ +// Package account is the user-facing self-service surface. Issue #225. +// +// Today it ships the GDPR data-export endpoint +// (POST /api/v1/account/data/export) — the public companion to the +// admin Settings → Privacy form. The endpoint is gated by the +// [settings.PrivacyAllowGDPRSelfService] toggle: when an operator +// flips it off the endpoint returns 403, and the admin UI hides the +// user-facing affordance. +// +// The export itself is a synchronous JSON blob (small payload, low +// throughput) keyed by the caller's user ID. Future revisions can +// promote it to a background job; the wire shape is forward-compatible. +package account + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router" + "github.com/Singleton-Solution/GoNext/packages/go/policy" + "github.com/Singleton-Solution/GoNext/packages/go/settings" +) + +// SettingsReader is the slice of [settings.Store] this package needs. +type SettingsReader interface { + Read(ctx context.Context, key string) (any, error) +} + +// ExportProducer assembles the per-user data export. Implementations +// pull from posts, comments, audit, etc. The contract is "return a +// JSON-serializable map keyed by domain"; the handler wraps it in the +// envelope. +type ExportProducer func(ctx context.Context, userID string) (map[string]any, error) + +// Deps is the dependency bag for [Mount]. +type Deps struct { + Settings SettingsReader + Producer ExportProducer + Logger *slog.Logger +} + +func (d Deps) validate() error { + if d.Settings == nil { + return errors.New("account: Deps.Settings is required") + } + if d.Producer == nil { + return errors.New("account: Deps.Producer is required") + } + return nil +} + +type handlers struct { + deps Deps + log *slog.Logger +} + +// Mount wires the account routes onto mux. base is typically +// "/api/v1/account". +func Mount(mux *http.ServeMux, base string, deps Deps) error { + if err := deps.validate(); err != nil { + return err + } + if deps.Logger == nil { + deps.Logger = slog.Default() + } + h := &handlers{deps: deps, log: deps.Logger} + base = strings.TrimRight(base, "/") + mux.Handle("POST "+base+"/data/export", http.HandlerFunc(h.export)) + return nil +} + +// export is the POST /api/v1/account/data/export handler. +// +// Auth path: +// - The caller MUST carry a principal on the context (the auth +// middleware enforces this upstream). Anonymous traffic gets 401. +// - The operator MUST have flipped on PrivacyAllowGDPRSelfService. +// A false value yields 403 with code "gdpr_disabled". +// +// On success the response is a JSON envelope: +// +// { +// "exported_at": "2025-01-01T00:00:00Z", +// "user_id": "", +// "data": { ... } +// } +// +// The producer is responsible for the contents of `data`. +func (h *handlers) export(w http.ResponseWriter, r *http.Request) { + p, ok := policy.FromContext(r.Context()) + if !ok || p.UserID == "" { + router.WriteError(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + v, err := h.deps.Settings.Read(r.Context(), settings.PrivacyAllowGDPRSelfService) + if err != nil { + h.log.ErrorContext(r.Context(), "account/export: read setting", + slog.String("key", settings.PrivacyAllowGDPRSelfService), + slog.Any("err", err)) + router.WriteError(w, http.StatusInternalServerError, "internal_error", "privacy gate unavailable") + return + } + enabled, _ := v.(bool) + if !enabled { + router.WriteError(w, http.StatusForbidden, "gdpr_disabled", + "the operator has disabled user-facing data exports") + return + } + + data, err := h.deps.Producer(r.Context(), p.UserID) + if err != nil { + h.log.ErrorContext(r.Context(), "account/export: producer", slog.Any("err", err)) + router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to assemble export") + return + } + if data == nil { + data = map[string]any{} + } + router.WriteJSON(w, http.StatusOK, exportEnvelope{ + ExportedAt: time.Now().UTC(), + UserID: p.UserID, + Data: data, + }) +} + +type exportEnvelope struct { + ExportedAt time.Time `json:"exported_at"` + UserID string `json:"user_id"` + Data map[string]any `json:"data"` +} + +// MarshalEnvelope is exported for the OpenAPI generator. Tests that +// assert against the wire shape can decode into the same struct via +// json.Unmarshal. +func MarshalEnvelope(userID string, data map[string]any) ([]byte, error) { + return json.Marshal(exportEnvelope{ + ExportedAt: time.Now().UTC(), + UserID: userID, + Data: data, + }) +} diff --git a/apps/api/internal/account/export_test.go b/apps/api/internal/account/export_test.go new file mode 100644 index 00000000..9af420f5 --- /dev/null +++ b/apps/api/internal/account/export_test.go @@ -0,0 +1,112 @@ +package account + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Singleton-Solution/GoNext/packages/go/policy" + "github.com/Singleton-Solution/GoNext/packages/go/settings" +) + +type fakeSettings struct { + value any + err error +} + +func (f *fakeSettings) Read(_ context.Context, _ string) (any, error) { + if f.err != nil { + return nil, f.err + } + return f.value, nil +} + +func newHarness(t *testing.T, sr SettingsReader, prod ExportProducer) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + if err := Mount(mux, "/api/v1/account", Deps{ + Settings: sr, + Producer: prod, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }); err != nil { + t.Fatalf("Mount: %v", err) + } + return mux +} + +func TestExport_Enabled_Success(t *testing.T) { + sr := &fakeSettings{value: true} + prod := func(_ context.Context, userID string) (map[string]any, error) { + return map[string]any{"posts": []string{"hello"}}, nil + } + mux := newHarness(t, sr, prod) + req := httptest.NewRequest(http.MethodPost, "/api/v1/account/data/export", nil) + req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{ + UserID: "u:1", Roles: []policy.Role{policy.RoleSubscriber}, + })) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var env map[string]any + _ = json.NewDecoder(rec.Body).Decode(&env) + if env["user_id"] != "u:1" { + t.Fatalf("unexpected user_id: %+v", env) + } +} + +func TestExport_Disabled_Forbidden(t *testing.T) { + sr := &fakeSettings{value: false} + prod := func(_ context.Context, _ string) (map[string]any, error) { + t.Fatal("producer should not run when disabled") + return nil, nil + } + mux := newHarness(t, sr, prod) + req := httptest.NewRequest(http.MethodPost, "/api/v1/account/data/export", nil) + req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{UserID: "u:1"})) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rec.Code) + } +} + +func TestExport_Anonymous_Unauthorized(t *testing.T) { + sr := &fakeSettings{value: true} + prod := func(_ context.Context, _ string) (map[string]any, error) { return nil, nil } + mux := newHarness(t, sr, prod) + req := httptest.NewRequest(http.MethodPost, "/api/v1/account/data/export", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} + +func TestExport_ProducerError(t *testing.T) { + sr := &fakeSettings{value: true} + prod := func(_ context.Context, _ string) (map[string]any, error) { + return nil, errors.New("boom") + } + mux := newHarness(t, sr, prod) + req := httptest.NewRequest(http.MethodPost, "/api/v1/account/data/export", nil) + req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{UserID: "u:1"})) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } +} + +// Guard that the privacy setting key is the same wire value we expect. +func TestPrivacyKeyConstant(t *testing.T) { + if settings.PrivacyAllowGDPRSelfService != "core.privacy.allow_gdpr_self_service" { + t.Fatalf("privacy key drift: %s", settings.PrivacyAllowGDPRSelfService) + } +} diff --git a/apps/api/internal/admin/impersonate/handler.go b/apps/api/internal/admin/impersonate/handler.go new file mode 100644 index 00000000..db5b22e4 --- /dev/null +++ b/apps/api/internal/admin/impersonate/handler.go @@ -0,0 +1,384 @@ +// Package impersonate is the super_admin "sign in as user" surface. +// Issue #211. +// +// Routes (mounted under base, typically /api/v1/admin/users): +// +// POST {base}/{id}/impersonate — mint a session as the target user +// +// A companion route is mounted under /api/v1/auth/impersonation: +// +// GET /api/v1/auth/impersonation — surface the banner state (is the +// current session impersonated? if +// so, who is the actor?) +// DELETE /api/v1/auth/impersonation — exit impersonation; tears down +// the impersonated session and +// rewrites the cookie back to the +// actor's original token. +// +// The handler is gated to the [policy.RoleSuperAdmin] role — operators +// at that tier are already trusted to read every secret in the system, +// so the additional "wear another user's hat" surface doesn't expand +// their blast radius. Audit emits an `admin.impersonation.started` +// event with both the actor (the super_admin) and the target user +// pinned, so the audit log later answers "who acted as user X between +// time A and time B". +// +// The minted session carries an `impersonation` flag in its data so +// the admin shell can render the "Signed in as X on behalf of Y" banner +// and offer an Exit affordance. The original session is NOT torn down +// — the banner's Exit affordance restores the operator to it. +package impersonate + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router" + "github.com/Singleton-Solution/GoNext/packages/go/audit" + "github.com/Singleton-Solution/GoNext/packages/go/policy" + "github.com/Singleton-Solution/GoNext/packages/go/session" +) + +// EventImpersonationStarted is the audit event type emitted for every +// successful impersonation. +const EventImpersonationStarted = "admin.impersonation.started" + +// SessionMinter is the slice of [session.Manager] this package needs. +type SessionMinter interface { + Create(ctx context.Context, userID string, data map[string]any, ttl, idleTTL time.Duration) (string, error) +} + +// SessionReader is the read surface used by the banner / exit +// endpoints. Implementations typically wrap [session.Manager]. +type SessionReader interface { + // Get fetches a session by raw token. Returns ErrNotFound when + // the token is unknown or expired. + Get(ctx context.Context, token string, idleTTL time.Duration) (session.Session, error) +} + +// SessionDeleter is the surface used to tear down an impersonated +// session on Exit. +type SessionDeleter interface { + Delete(ctx context.Context, token string) error +} + +// UserLookup returns true iff the target user exists. Implementations +// typically wrap a users.Store; tests can pass an inline closure. +type UserLookup func(ctx context.Context, userID uuid.UUID) (exists bool, err error) + +// AuditEmitter is the audit surface this package depends on. +type AuditEmitter interface { + Emit(ctx context.Context, eventType string, opts ...audit.EmitOption) error +} + +// Deps is the dependency bag for [Mount]. +type Deps struct { + Sessions SessionMinter + Reader SessionReader + Deleter SessionDeleter + Policy policy.Policy + Audit AuditEmitter + Logger *slog.Logger + UserLookup UserLookup + // TTL is the absolute lifetime of the impersonated session. + // Defaults to 30 minutes when zero — impersonation is a heightened + // privilege and shouldn't outlive the operator's coffee. + TTL time.Duration + // IdleTTL is the rolling-idle window. Defaults to 15 minutes when + // zero. + IdleTTL time.Duration + // CookieName overrides the session cookie name written on the + // response. Defaults to [session.CookieName]. + CookieName string + // CookieSecure controls the Secure attribute on the cookie. Set + // true in production over HTTPS. + CookieSecure bool +} + +func (d Deps) validate() error { + if d.Sessions == nil { + return errors.New("impersonate: Deps.Sessions is required") + } + if d.Policy == nil { + return errors.New("impersonate: Deps.Policy is required") + } + if d.Audit == nil { + return errors.New("impersonate: Deps.Audit is required") + } + return nil +} + +type handlers struct { + deps Deps + log *slog.Logger +} + +// Mount wires the impersonation route onto mux. Gate is enforced by +// the handler itself rather than a [policy.Require] gate so we can +// require a specific role (super_admin) rather than a capability. +func Mount(mux *http.ServeMux, base string, deps Deps) error { + if err := deps.validate(); err != nil { + return err + } + if deps.Logger == nil { + deps.Logger = slog.Default() + } + if deps.TTL == 0 { + deps.TTL = 30 * time.Minute + } + if deps.IdleTTL == 0 { + deps.IdleTTL = 15 * time.Minute + } + if deps.CookieName == "" { + deps.CookieName = session.CookieName + } + h := &handlers{deps: deps, log: deps.Logger} + base = strings.TrimRight(base, "/") + mux.Handle("POST "+base+"/{id}/impersonate", http.HandlerFunc(h.start)) + return nil +} + +// MountBanner wires the banner-state endpoints under the auth tree: +// +// GET /api/v1/auth/impersonation — surface banner state +// DELETE /api/v1/auth/impersonation — exit impersonation +// +// authBase is typically "/api/v1/auth/impersonation". Both handlers +// require Reader and Deleter to be set on Deps. +func MountBanner(mux *http.ServeMux, authBase string, deps Deps) error { + if deps.Reader == nil { + return errors.New("impersonate: MountBanner requires Deps.Reader") + } + if deps.Deleter == nil { + return errors.New("impersonate: MountBanner requires Deps.Deleter") + } + if deps.Logger == nil { + deps.Logger = slog.Default() + } + if deps.CookieName == "" { + deps.CookieName = session.CookieName + } + if deps.IdleTTL == 0 { + deps.IdleTTL = 15 * time.Minute + } + h := &handlers{deps: deps, log: deps.Logger} + authBase = strings.TrimRight(authBase, "/") + mux.Handle("GET "+authBase, http.HandlerFunc(h.whoami)) + mux.Handle("DELETE "+authBase, http.HandlerFunc(h.exit)) + return nil +} + +// whoami surfaces the banner state for the current session. Returns +// {"impersonation": false} for a normal session and {"impersonation": +// true, "actor_user_id": "...", "target_user_id": "..."} when the +// current session is an impersonation. +func (h *handlers) whoami(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(h.deps.CookieName) + if err != nil || c == nil || c.Value == "" { + router.WriteJSON(w, http.StatusOK, map[string]any{"impersonation": false}) + return + } + sess, err := h.deps.Reader.Get(r.Context(), c.Value, h.deps.IdleTTL) + if err != nil { + router.WriteJSON(w, http.StatusOK, map[string]any{"impersonation": false}) + return + } + imp, _ := sess.Data["impersonation"].(bool) + if !imp { + router.WriteJSON(w, http.StatusOK, map[string]any{"impersonation": false}) + return + } + actor, _ := sess.Data["actor_user_id"].(string) + router.WriteJSON(w, http.StatusOK, map[string]any{ + "impersonation": true, + "actor_user_id": actor, + "target_user_id": sess.UserID, + }) +} + +// exit tears down the impersonated session and rewrites the cookie +// back to the actor's original token. If the session in question +// isn't actually an impersonation, this is a no-op (idempotent). +func (h *handlers) exit(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(h.deps.CookieName) + if err != nil || c == nil || c.Value == "" { + router.WriteError(w, http.StatusUnauthorized, "no_session", "no session cookie") + return + } + sess, err := h.deps.Reader.Get(r.Context(), c.Value, h.deps.IdleTTL) + if err != nil { + router.WriteError(w, http.StatusUnauthorized, "no_session", "session not found") + return + } + imp, _ := sess.Data["impersonation"].(bool) + if !imp { + // Not impersonating; clear the response without tearing down + // the regular session. + router.WriteJSON(w, http.StatusOK, map[string]any{"exited": false}) + return + } + originalToken, _ := sess.Data["original_token"].(string) + // Tear down the impersonated session. + if err := h.deps.Deleter.Delete(r.Context(), c.Value); err != nil { + h.log.WarnContext(r.Context(), "impersonate: delete failed", slog.Any("err", err)) + } + // Restore the original cookie. If no original_token was recorded + // (e.g. operator impersonated from a fresh tab), the cookie is + // cleared and the operator must log in again — fail-closed. + if originalToken != "" { + http.SetCookie(w, &http.Cookie{ + Name: h.deps.CookieName, + Value: originalToken, + Path: "/", + HttpOnly: true, + Secure: h.deps.CookieSecure, + SameSite: http.SameSiteLaxMode, + }) + } else { + http.SetCookie(w, &http.Cookie{ + Name: h.deps.CookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: h.deps.CookieSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + } + router.WriteJSON(w, http.StatusOK, map[string]any{"exited": true}) +} + +// start is the POST handler. Auth path: +// +// 1. The caller MUST be authenticated (principal on context). +// 2. The caller MUST carry the super_admin role. Anything less and +// we return 403 — a regular admin can NOT impersonate. +// 3. The target user MUST exist (else 404). The UserLookup is +// optional; if not supplied, we skip the existence check and +// trust the caller — useful for tests but operators should always +// wire one in. +// 4. We mint a session as the target user, with metadata carrying +// the original operator's user ID and a true `impersonation` flag. +// 5. We emit `admin.impersonation.started` to the audit log, pinning +// both actor (super_admin) and target. +// 6. We Set-Cookie the new session token on the response, which +// swaps the browser to the impersonated session on the next +// request. The operator's original cookie value is recorded in +// the response payload so the admin UI can restore it on Exit. +func (h *handlers) start(w http.ResponseWriter, r *http.Request) { + p, ok := policy.FromContext(r.Context()) + if !ok || p.UserID == "" { + router.WriteError(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + if !hasSuperAdmin(p) { + router.WriteError(w, http.StatusForbidden, "forbidden", "super_admin required") + return + } + targetID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + router.WriteError(w, http.StatusBadRequest, "invalid_id", "id is not a valid uuid") + return + } + if h.deps.UserLookup != nil { + ok, err := h.deps.UserLookup(r.Context(), targetID) + if err != nil { + h.log.ErrorContext(r.Context(), "impersonate: user lookup", slog.Any("err", err)) + router.WriteError(w, http.StatusInternalServerError, "internal_error", "user lookup failed") + return + } + if !ok { + router.WriteError(w, http.StatusNotFound, "not_found", "user does not exist") + return + } + } + + // Record the original session token so the banner's Exit + // affordance can restore it. The cookie value is the raw token; + // we read it once and stuff it in the new session's data. + var originalToken string + if c, err := r.Cookie(h.deps.CookieName); err == nil && c != nil { + originalToken = c.Value + } + + data := map[string]any{ + "impersonation": true, + "actor_user_id": p.UserID, + "original_token": originalToken, + "impersonated_at": time.Now().UTC().Format(time.RFC3339), + } + token, err := h.deps.Sessions.Create(r.Context(), targetID.String(), data, h.deps.TTL, h.deps.IdleTTL) + if err != nil { + h.log.ErrorContext(r.Context(), "impersonate: session create", slog.Any("err", err)) + router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to mint session") + return + } + + // Emit audit. Failures here do NOT roll back the session — the + // audit log entry is best-effort, and a missing entry is better + // than refusing the operator's request after they've already been + // switched. + if err := h.deps.Audit.Emit(r.Context(), EventImpersonationStarted, + audit.WithActorOverride(p.UserID), + audit.WithTarget("user", targetID.String()), + audit.WithMetadata(map[string]any{ + "impersonator_user_id": p.UserID, + "target_user_id": targetID.String(), + }), + audit.WithSeverity(audit.SeverityWarning), + ); err != nil { + h.log.WarnContext(r.Context(), "impersonate: audit emit failed", + slog.String("event", EventImpersonationStarted), + slog.Any("err", err)) + } + + // Set the impersonated session cookie. The browser will use this + // for subsequent requests; the original cookie value is encoded in + // the response body for the Exit flow. + http.SetCookie(w, &http.Cookie{ + Name: h.deps.CookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: h.deps.CookieSecure, + SameSite: http.SameSiteLaxMode, + MaxAge: int(h.deps.TTL.Seconds()), + }) + + router.WriteJSON(w, http.StatusOK, map[string]any{ + "impersonated_user_id": targetID.String(), + "actor_user_id": p.UserID, + "expires_in_seconds": int(h.deps.TTL.Seconds()), + }) +} + +// hasSuperAdmin reports whether the principal carries the super_admin +// role. +func hasSuperAdmin(p policy.Principal) bool { + for _, r := range p.Roles { + if r == policy.RoleSuperAdmin { + return true + } + } + return false +} + +// EncodeResponse is exported so the admin UI's E2E tests can decode +// the JSON shape from a recorded fixture without dragging in the +// internal map literal. Returns the canonical JSON bytes for a +// success response. +func EncodeResponse(actorID, targetID string, ttl time.Duration) []byte { + out, _ := json.Marshal(map[string]any{ + "impersonated_user_id": targetID, + "actor_user_id": actorID, + "expires_in_seconds": int(ttl.Seconds()), + }) + return out +} diff --git a/apps/api/internal/admin/impersonate/handler_test.go b/apps/api/internal/admin/impersonate/handler_test.go new file mode 100644 index 00000000..2a8b356d --- /dev/null +++ b/apps/api/internal/admin/impersonate/handler_test.go @@ -0,0 +1,271 @@ +package impersonate + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/Singleton-Solution/GoNext/packages/go/audit" + "github.com/Singleton-Solution/GoNext/packages/go/policy" + "github.com/Singleton-Solution/GoNext/packages/go/session" +) + +// fakeSessions records what was minted and returns a stable token. +type fakeSessions struct { + lastUserID string + lastData map[string]any +} + +func (f *fakeSessions) Create(_ context.Context, userID string, data map[string]any, _, _ time.Duration) (string, error) { + f.lastUserID = userID + f.lastData = data + return "minted-token", nil +} + +func newHarness(t *testing.T, lookup UserLookup) (*http.ServeMux, *fakeSessions, *audit.MemoryStore) { + t.Helper() + store := audit.NewMemoryStore() + emitter := audit.NewEmitter(store) + sessions := &fakeSessions{} + pol := policy.NewBasicPolicy(policy.DefaultRoleCapabilities()) + mux := http.NewServeMux() + if err := Mount(mux, "/api/v1/admin/users", Deps{ + Sessions: sessions, + Policy: pol, + Audit: emitter, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + UserLookup: lookup, + }); err != nil { + t.Fatalf("Mount: %v", err) + } + return mux, sessions, store +} + +func TestImpersonate_SuperAdminSuccess(t *testing.T) { + target := uuid.New() + mux, fs, audStore := newHarness(t, func(_ context.Context, id uuid.UUID) (bool, error) { + return id == target, nil + }) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+target.String()+"/impersonate", nil) + req.AddCookie(&http.Cookie{Name: "sid", Value: "original-token"}) + req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{ + UserID: "actor:1", Roles: []policy.Role{policy.RoleSuperAdmin}, + })) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if fs.lastUserID != target.String() { + t.Fatalf("expected target=%s, got %s", target.String(), fs.lastUserID) + } + if fs.lastData["impersonation"] != true { + t.Fatalf("expected impersonation flag in session data: %+v", fs.lastData) + } + if fs.lastData["original_token"] != "original-token" { + t.Fatalf("expected original_token recorded: %+v", fs.lastData) + } + // Audit event should be present with target=user/. + evs, _ := audStore.List(context.Background(), audit.Filter{}) + if len(evs) != 1 { + t.Fatalf("expected 1 audit event, got %d", len(evs)) + } + if evs[0].EventType != EventImpersonationStarted { + t.Fatalf("event type %q", evs[0].EventType) + } +} + +func TestImpersonate_AdminDenied(t *testing.T) { + target := uuid.New() + mux, _, _ := newHarness(t, nil) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+target.String()+"/impersonate", nil) + req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{ + UserID: "u:1", Roles: []policy.Role{policy.RoleAdmin}, + })) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestImpersonate_AnonymousDenied(t *testing.T) { + mux, _, _ := newHarness(t, nil) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+uuid.New().String()+"/impersonate", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} + +func TestImpersonate_TargetNotFound(t *testing.T) { + mux, _, _ := newHarness(t, func(_ context.Context, _ uuid.UUID) (bool, error) { return false, nil }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+uuid.New().String()+"/impersonate", nil) + req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{ + UserID: "actor:1", Roles: []policy.Role{policy.RoleSuperAdmin}, + })) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } +} + +// fakeReader records lookups and returns a canned session. +type fakeReader struct { + sess session.Session + err error +} + +func (f *fakeReader) Get(_ context.Context, _ string, _ time.Duration) (session.Session, error) { + return f.sess, f.err +} + +type fakeDeleter struct { + deleted string +} + +func (f *fakeDeleter) Delete(_ context.Context, token string) error { + f.deleted = token + return nil +} + +func TestBanner_Whoami_NotImpersonating(t *testing.T) { + reader := &fakeReader{sess: session.Session{UserID: "u:1", Data: map[string]any{}}} + deleter := &fakeDeleter{} + mux := http.NewServeMux() + if err := MountBanner(mux, "/api/v1/auth/impersonation", Deps{ + Reader: reader, + Deleter: deleter, + Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()), + Audit: audit.NewEmitter(audit.NewMemoryStore()), + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }); err != nil { + t.Fatalf("MountBanner: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/impersonation", nil) + req.AddCookie(&http.Cookie{Name: "sid", Value: "tok"}) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + var body map[string]any + _ = json.NewDecoder(rec.Body).Decode(&body) + if body["impersonation"] != false { + t.Fatalf("expected impersonation=false, got %+v", body) + } +} + +func TestBanner_Whoami_Impersonating(t *testing.T) { + reader := &fakeReader{sess: session.Session{ + UserID: "target:1", + Data: map[string]any{ + "impersonation": true, + "actor_user_id": "actor:1", + "original_token": "orig-token", + }, + }} + deleter := &fakeDeleter{} + mux := http.NewServeMux() + _ = MountBanner(mux, "/api/v1/auth/impersonation", Deps{ + Reader: reader, Deleter: deleter, + Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()), + Audit: audit.NewEmitter(audit.NewMemoryStore()), + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/impersonation", nil) + req.AddCookie(&http.Cookie{Name: "sid", Value: "tok"}) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + var body map[string]any + _ = json.NewDecoder(rec.Body).Decode(&body) + if body["impersonation"] != true { + t.Fatalf("expected impersonation=true, got %+v", body) + } + if body["actor_user_id"] != "actor:1" || body["target_user_id"] != "target:1" { + t.Fatalf("unexpected body: %+v", body) + } +} + +func TestBanner_Exit_TearsDownAndRestoresCookie(t *testing.T) { + reader := &fakeReader{sess: session.Session{ + UserID: "target:1", + Data: map[string]any{ + "impersonation": true, + "original_token": "orig-token", + }, + }} + deleter := &fakeDeleter{} + mux := http.NewServeMux() + _ = MountBanner(mux, "/api/v1/auth/impersonation", Deps{ + Reader: reader, Deleter: deleter, + Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()), + Audit: audit.NewEmitter(audit.NewMemoryStore()), + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/impersonation", nil) + req.AddCookie(&http.Cookie{Name: "sid", Value: "imp-token"}) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if deleter.deleted != "imp-token" { + t.Fatalf("expected impersonated token deleted, got %q", deleter.deleted) + } + cookies := rec.Result().Cookies() + found := false + for _, c := range cookies { + if c.Value == "orig-token" { + found = true + break + } + } + if !found { + t.Fatalf("expected original cookie restored: %+v", cookies) + } +} + +func TestImpersonate_CookieIsSet(t *testing.T) { + target := uuid.New() + mux, _, _ := newHarness(t, func(_ context.Context, id uuid.UUID) (bool, error) { + return id == target, nil + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+target.String()+"/impersonate", nil) + req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{ + UserID: "actor:1", Roles: []policy.Role{policy.RoleSuperAdmin}, + })) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + res := rec.Result() + defer res.Body.Close() + found := false + for _, c := range res.Cookies() { + if c.Value == "minted-token" { + found = true + break + } + } + if !found { + t.Fatalf("expected impersonated cookie in response: %v", res.Cookies()) + } + + var body map[string]any + _ = json.NewDecoder(res.Body).Decode(&body) + if body["impersonated_user_id"] != target.String() { + t.Fatalf("unexpected body: %+v", body) + } + _ = io.Discard +} diff --git a/apps/api/internal/admin/menus/handler.go b/apps/api/internal/admin/menus/handler.go new file mode 100644 index 00000000..e960d1fd --- /dev/null +++ b/apps/api/internal/admin/menus/handler.go @@ -0,0 +1,328 @@ +// Package menus is the admin REST surface for navigation menus and +// their items — issue #54. +// +// Routes (mounted under base, typically /api/v1/admin/menus): +// +// GET {base} — list menus +// POST {base} — create menu +// GET {base}/{id} — get menu (with items) +// PUT {base}/{id} — update menu (name/attrs only) +// DELETE {base}/{id} — delete menu (cascade items) +// POST {base}/{id}/items — append item +// PUT {base}/{id}/items/{itemID} — update item +// DELETE {base}/{id}/items/{itemID} — delete item +// POST {base}/{id}/items/reorder — drag-drop reorder bulk +// +// The whole sub-tree is gated by manage_themes — operators that can +// change the theme are trusted to change the navigation that the theme +// renders. +package menus + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router" + "github.com/Singleton-Solution/GoNext/packages/go/menus" + "github.com/Singleton-Solution/GoNext/packages/go/policy" +) + +// Deps is the dependency bag for [Mount]. +type Deps struct { + Store menus.Store + Policy policy.Policy + Logger *slog.Logger +} + +func (d Deps) validate() error { + if d.Store == nil { + return errors.New("admin/menus: Deps.Store is required") + } + if d.Policy == nil { + return errors.New("admin/menus: Deps.Policy is required") + } + return nil +} + +type handlers struct { + store menus.Store + policy policy.Policy + logger *slog.Logger +} + +// Mount wires the menus admin routes onto mux. The whole sub-tree is +// gated by edit_theme_options. +func Mount(mux *http.ServeMux, base string, deps Deps) error { + if err := deps.validate(); err != nil { + return err + } + if deps.Logger == nil { + deps.Logger = slog.Default() + } + h := &handlers{store: deps.Store, policy: deps.Policy, logger: deps.Logger} + base = strings.TrimRight(base, "/") + gate := policy.Require(deps.Policy, policy.CapManageThemes) + mux.Handle("GET "+base, gate(http.HandlerFunc(h.list))) + mux.Handle("POST "+base, gate(http.HandlerFunc(h.create))) + mux.Handle("GET "+base+"/{id}", gate(http.HandlerFunc(h.get))) + mux.Handle("PUT "+base+"/{id}", gate(http.HandlerFunc(h.update))) + mux.Handle("DELETE "+base+"/{id}", gate(http.HandlerFunc(h.deleteMenu))) + mux.Handle("POST "+base+"/{id}/items", gate(http.HandlerFunc(h.createItem))) + mux.Handle("PUT "+base+"/{id}/items/{itemID}", gate(http.HandlerFunc(h.updateItem))) + mux.Handle("DELETE "+base+"/{id}/items/{itemID}", gate(http.HandlerFunc(h.deleteItem))) + mux.Handle("POST "+base+"/{id}/items/reorder", gate(http.HandlerFunc(h.reorder))) + return nil +} + +type menuRequest struct { + Slug string `json:"slug"` + Name string `json:"name"` + Attrs json.RawMessage `json:"attrs,omitempty"` +} + +type itemRequest struct { + Path string `json:"path"` + Label string `json:"label"` + URL string `json:"url"` + ObjectType string `json:"object_type,omitempty"` + ObjectID *uuid.UUID `json:"object_id,omitempty"` + Attrs json.RawMessage `json:"attrs,omitempty"` +} + +type reorderRequest struct { + Items []struct { + ID uuid.UUID `json:"id"` + Path string `json:"path"` + } `json:"items"` +} + +func (h *handlers) list(w http.ResponseWriter, r *http.Request) { + out, err := h.store.ListMenus(r.Context()) + if err != nil { + h.logger.ErrorContext(r.Context(), "admin/menus: list", slog.Any("err", err)) + router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to list menus") + return + } + if out == nil { + out = []menus.Menu{} + } + router.WriteJSON(w, http.StatusOK, map[string]any{"menus": out}) +} + +func (h *handlers) create(w http.ResponseWriter, r *http.Request) { + var req menuRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + router.WriteError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON") + return + } + m, err := h.store.CreateMenu(r.Context(), menus.Menu{ + Slug: strings.TrimSpace(req.Slug), + Name: strings.TrimSpace(req.Name), + Attrs: req.Attrs, + }) + if err != nil { + writeErr(w, err) + return + } + router.WriteJSON(w, http.StatusCreated, m) +} + +func (h *handlers) get(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(r, "id") + if !ok { + router.WriteError(w, http.StatusBadRequest, "invalid_id", "id is not a valid uuid") + return + } + bundle, err := h.store.GetWithItems(r.Context(), id) + if err != nil { + writeErr(w, err) + return + } + if bundle.Items == nil { + bundle.Items = []menus.MenuItem{} + } + router.WriteJSON(w, http.StatusOK, bundle) +} + +func (h *handlers) update(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(r, "id") + if !ok { + router.WriteError(w, http.StatusBadRequest, "invalid_id", "id is not a valid uuid") + return + } + var req menuRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + router.WriteError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON") + return + } + // Re-fetch to get the slug — UpdateMenu pins it from the existing + // row, but the validator runs on the input so we pass the right + // slug through to satisfy the regex check. + existing, err := h.store.GetMenu(r.Context(), id) + if err != nil { + writeErr(w, err) + return + } + out, err := h.store.UpdateMenu(r.Context(), menus.Menu{ + ID: id, + Slug: existing.Slug, + Name: strings.TrimSpace(req.Name), + Attrs: req.Attrs, + }) + if err != nil { + writeErr(w, err) + return + } + router.WriteJSON(w, http.StatusOK, out) +} + +func (h *handlers) deleteMenu(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(r, "id") + if !ok { + router.WriteError(w, http.StatusBadRequest, "invalid_id", "id is not a valid uuid") + return + } + if err := h.store.DeleteMenu(r.Context(), id); err != nil { + writeErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *handlers) createItem(w http.ResponseWriter, r *http.Request) { + menuID, ok := parseUUID(r, "id") + if !ok { + router.WriteError(w, http.StatusBadRequest, "invalid_id", "id is not a valid uuid") + return + } + var req itemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + router.WriteError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON") + return + } + out, err := h.store.CreateItem(r.Context(), menus.MenuItem{ + MenuID: menuID, + Path: strings.TrimSpace(req.Path), + Label: strings.TrimSpace(req.Label), + URL: strings.TrimSpace(req.URL), + ObjectType: strings.TrimSpace(req.ObjectType), + ObjectID: req.ObjectID, + Attrs: req.Attrs, + }) + if err != nil { + writeErr(w, err) + return + } + router.WriteJSON(w, http.StatusCreated, out) +} + +func (h *handlers) updateItem(w http.ResponseWriter, r *http.Request) { + menuID, ok := parseUUID(r, "id") + if !ok { + router.WriteError(w, http.StatusBadRequest, "invalid_id", "id is not a valid uuid") + return + } + itemID, ok := parseUUID(r, "itemID") + if !ok { + router.WriteError(w, http.StatusBadRequest, "invalid_id", "itemID is not a valid uuid") + return + } + var req itemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + router.WriteError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON") + return + } + out, err := h.store.UpdateItem(r.Context(), menus.MenuItem{ + ID: itemID, + MenuID: menuID, + Path: strings.TrimSpace(req.Path), + Label: strings.TrimSpace(req.Label), + URL: strings.TrimSpace(req.URL), + ObjectType: strings.TrimSpace(req.ObjectType), + ObjectID: req.ObjectID, + Attrs: req.Attrs, + }) + if err != nil { + writeErr(w, err) + return + } + router.WriteJSON(w, http.StatusOK, out) +} + +func (h *handlers) deleteItem(w http.ResponseWriter, r *http.Request) { + itemID, ok := parseUUID(r, "itemID") + if !ok { + router.WriteError(w, http.StatusBadRequest, "invalid_id", "itemID is not a valid uuid") + return + } + if err := h.store.DeleteItem(r.Context(), itemID); err != nil { + writeErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *handlers) reorder(w http.ResponseWriter, r *http.Request) { + menuID, ok := parseUUID(r, "id") + if !ok { + router.WriteError(w, http.StatusBadRequest, "invalid_id", "id is not a valid uuid") + return + } + var req reorderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + router.WriteError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON") + return + } + // Pull current items so we can carry their non-mutated columns + // (label/url/attrs/etc) into the validator. + bundle, err := h.store.GetWithItems(r.Context(), menuID) + if err != nil { + writeErr(w, err) + return + } + indexed := make(map[uuid.UUID]menus.MenuItem, len(bundle.Items)) + for _, mi := range bundle.Items { + indexed[mi.ID] = mi + } + out := make([]menus.MenuItem, 0, len(req.Items)) + for _, in := range req.Items { + existing, ok := indexed[in.ID] + if !ok { + router.WriteError(w, http.StatusBadRequest, "unknown_item", + fmt.Sprintf("item %s does not belong to this menu", in.ID)) + return + } + existing.Path = strings.TrimSpace(in.Path) + out = append(out, existing) + } + if err := h.store.ReorderItems(r.Context(), menuID, out); err != nil { + writeErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func parseUUID(r *http.Request, name string) (uuid.UUID, bool) { + id, err := uuid.Parse(r.PathValue(name)) + if err != nil { + return uuid.Nil, false + } + return id, true +} + +func writeErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, menus.ErrInvalidMenu), errors.Is(err, menus.ErrInvalidItem): + router.WriteError(w, http.StatusBadRequest, "invalid_input", err.Error()) + case errors.Is(err, menus.ErrNotFound): + router.WriteError(w, http.StatusNotFound, "not_found", err.Error()) + default: + router.WriteError(w, http.StatusInternalServerError, "internal_error", "menus store error") + } +} diff --git a/apps/api/internal/admin/menus/handler_test.go b/apps/api/internal/admin/menus/handler_test.go new file mode 100644 index 00000000..95f75669 --- /dev/null +++ b/apps/api/internal/admin/menus/handler_test.go @@ -0,0 +1,127 @@ +package menus + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + pkgmenus "github.com/Singleton-Solution/GoNext/packages/go/menus" + "github.com/Singleton-Solution/GoNext/packages/go/policy" +) + +const base = "/api/v1/admin/menus" + +type harness struct { + mux *http.ServeMux + store *pkgmenus.MemoryStore +} + +func newHarness(t *testing.T) *harness { + t.Helper() + store := pkgmenus.NewMemoryStore() + pol := policy.NewBasicPolicy(policy.DefaultRoleCapabilities()) + mux := http.NewServeMux() + if err := Mount(mux, base, Deps{ + Store: store, + Policy: pol, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }); err != nil { + t.Fatalf("Mount: %v", err) + } + return &harness{mux: mux, store: store} +} + +func adminPrincipal() policy.Principal { + return policy.Principal{UserID: "u:1", Roles: []policy.Role{policy.RoleAdmin}} +} + +func subscriberPrincipal() policy.Principal { + return policy.Principal{UserID: "u:2", Roles: []policy.Role{policy.RoleSubscriber}} +} + +func (h *harness) do(req *http.Request, pr *policy.Principal) *httptest.ResponseRecorder { + if pr != nil { + req = req.WithContext(policy.WithPrincipal(req.Context(), *pr)) + } + rec := httptest.NewRecorder() + h.mux.ServeHTTP(rec, req) + return rec +} + +func TestCreateMenu(t *testing.T) { + h := newHarness(t) + pr := adminPrincipal() + body, _ := json.Marshal(map[string]string{"slug": "primary", "name": "Primary"}) + rec := h.do(httptest.NewRequest(http.MethodPost, base, bytes.NewReader(body)), &pr) + if rec.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var m pkgmenus.Menu + if err := json.NewDecoder(rec.Body).Decode(&m); err != nil { + t.Fatalf("decode: %v", err) + } + if m.Slug != "primary" || m.Name != "Primary" { + t.Fatalf("created shape wrong: %+v", m) + } +} + +func TestSubscriberForbidden(t *testing.T) { + h := newHarness(t) + pr := subscriberPrincipal() + rec := h.do(httptest.NewRequest(http.MethodGet, base, nil), &pr) + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rec.Code) + } +} + +func TestItemsCRUDAndReorder(t *testing.T) { + h := newHarness(t) + pr := adminPrincipal() + + // Create menu. + body, _ := json.Marshal(map[string]string{"slug": "x", "name": "X"}) + rec := h.do(httptest.NewRequest(http.MethodPost, base, bytes.NewReader(body)), &pr) + var m pkgmenus.Menu + _ = json.NewDecoder(rec.Body).Decode(&m) + + // Append two items. + body, _ = json.Marshal(map[string]string{"path": "001", "label": "Home", "url": "/"}) + rec = h.do(httptest.NewRequest(http.MethodPost, base+"/"+m.ID.String()+"/items", bytes.NewReader(body)), &pr) + if rec.Code != http.StatusCreated { + t.Fatalf("create item 1: %d %s", rec.Code, rec.Body.String()) + } + var item1 pkgmenus.MenuItem + _ = json.NewDecoder(rec.Body).Decode(&item1) + + body, _ = json.Marshal(map[string]string{"path": "002", "label": "About", "url": "/about"}) + rec = h.do(httptest.NewRequest(http.MethodPost, base+"/"+m.ID.String()+"/items", bytes.NewReader(body)), &pr) + var item2 pkgmenus.MenuItem + _ = json.NewDecoder(rec.Body).Decode(&item2) + + // Reorder — swap them. + body, _ = json.Marshal(map[string]any{ + "items": []map[string]string{ + {"id": item1.ID.String(), "path": "002"}, + {"id": item2.ID.String(), "path": "001"}, + }, + }) + rec = h.do(httptest.NewRequest(http.MethodPost, base+"/"+m.ID.String()+"/items/reorder", bytes.NewReader(body)), &pr) + if rec.Code != http.StatusNoContent { + t.Fatalf("reorder: %d %s", rec.Code, rec.Body.String()) + } + + // Verify ordering. + rec = h.do(httptest.NewRequest(http.MethodGet, base+"/"+m.ID.String(), nil), &pr) + if rec.Code != http.StatusOK { + t.Fatalf("get: %d %s", rec.Code, rec.Body.String()) + } + var bundle pkgmenus.MenuWithItems + _ = json.NewDecoder(rec.Body).Decode(&bundle) + if len(bundle.Items) != 2 || bundle.Items[0].Label != "About" { + t.Fatalf("expected About first after reorder: %+v", bundle.Items) + } +} diff --git a/apps/api/internal/admin/pluginpages/handler.go b/apps/api/internal/admin/pluginpages/handler.go new file mode 100644 index 00000000..919db48f --- /dev/null +++ b/apps/api/internal/admin/pluginpages/handler.go @@ -0,0 +1,135 @@ +// Package pluginpages exposes the active plugins' admin_pages +// declarations to the admin shell. Issue #228. +// +// Route (mounted under base, typically /api/v1/admin/plugin-pages): +// +// GET {base} — flat list of {plugin, slug, label, icon, capability} +// +// The admin sidebar fetches this once per page load, filters by the +// current viewer's capabilities, and renders one Sidebar entry under +// the "Plugins" section per declared page. The plugin's frontend +// host (under /plugins/{plugin}/{slug}) is responsible for the page +// itself; this surface only exposes the manifest declarations. +package pluginpages + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "sort" + "strings" + + "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router" + "github.com/Singleton-Solution/GoNext/packages/go/plugins/lifecycle" + "github.com/Singleton-Solution/GoNext/packages/go/plugins/manifest" + "github.com/Singleton-Solution/GoNext/packages/go/policy" +) + +// PluginLister is the narrow [lifecycle.Manager] surface this package +// uses. Implementations typically wrap the production Manager; tests +// pass an inline closure. +type PluginLister interface { + List(ctx context.Context) ([]lifecycle.Plugin, error) +} + +// Deps is the dependency bag for [Mount]. +type Deps struct { + Manager PluginLister + Logger *slog.Logger +} + +func (d Deps) validate() error { + if d.Manager == nil { + return errors.New("pluginpages: Deps.Manager is required") + } + return nil +} + +// PageView is the per-page wire shape returned by the list endpoint. +// One PageView per AdminPage declaration in every active plugin's +// manifest. +type PageView struct { + Plugin string `json:"plugin"` + Slug string `json:"slug"` + Label string `json:"label"` + Icon string `json:"icon,omitempty"` + Capability string `json:"capability,omitempty"` +} + +type listResponse struct { + Pages []PageView `json:"pages"` +} + +type handlers struct { + mgr PluginLister + log *slog.Logger +} + +// Mount wires the route onto mux. base is typically +// "/api/v1/admin/plugin-pages". +func Mount(mux *http.ServeMux, base string, deps Deps) error { + if err := deps.validate(); err != nil { + return err + } + if deps.Logger == nil { + deps.Logger = slog.Default() + } + h := &handlers{mgr: deps.Manager, log: deps.Logger} + base = strings.TrimRight(base, "/") + mux.Handle("GET "+base, http.HandlerFunc(h.list)) + return nil +} + +// list walks every active plugin's manifest, pulls out the admin_pages +// list, and returns the flattened set. Pages are sorted alphabetically +// by (plugin, slug) for a stable wire ordering. +// +// Capability filtering: the response includes the declared +// `capability` per page; the admin shell filters client-side using +// the viewer's principal. This keeps the server surface principal- +// neutral (other clients — e.g. an OpenAPI explorer — see the full +// declared set). +func (h *handlers) list(w http.ResponseWriter, r *http.Request) { + p, ok := policy.FromContext(r.Context()) + if !ok || p.UserID == "" { + router.WriteError(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + plugins, err := h.mgr.List(r.Context()) + if err != nil { + h.log.ErrorContext(r.Context(), "pluginpages: list", slog.Any("err", err)) + router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to list plugins") + return + } + out := make([]PageView, 0) + for _, pl := range plugins { + if pl.State != lifecycle.StateActive { + continue + } + var m manifest.Manifest + if err := json.Unmarshal(pl.Manifest, &m); err != nil { + h.log.WarnContext(r.Context(), "pluginpages: skip plugin with unparseable manifest", + slog.String("plugin", pl.Slug), + slog.Any("err", err)) + continue + } + for _, page := range m.AdminPages { + out = append(out, PageView{ + Plugin: pl.Slug, + Slug: page.Slug, + Label: page.Label, + Icon: page.Icon, + Capability: page.Capability, + }) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Plugin != out[j].Plugin { + return out[i].Plugin < out[j].Plugin + } + return out[i].Slug < out[j].Slug + }) + router.WriteJSON(w, http.StatusOK, listResponse{Pages: out}) +} diff --git a/apps/api/internal/admin/pluginpages/handler_test.go b/apps/api/internal/admin/pluginpages/handler_test.go new file mode 100644 index 00000000..fdabe6f1 --- /dev/null +++ b/apps/api/internal/admin/pluginpages/handler_test.go @@ -0,0 +1,122 @@ +package pluginpages + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Singleton-Solution/GoNext/packages/go/plugins/lifecycle" + "github.com/Singleton-Solution/GoNext/packages/go/policy" +) + +type fakeLister struct { + plugins []lifecycle.Plugin +} + +func (f *fakeLister) List(_ context.Context) ([]lifecycle.Plugin, error) { + return f.plugins, nil +} + +func newHarness(t *testing.T, plugins []lifecycle.Plugin) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + if err := Mount(mux, "/api/v1/admin/plugin-pages", Deps{ + Manager: &fakeLister{plugins: plugins}, + Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)), + }); err != nil { + t.Fatalf("Mount: %v", err) + } + return mux +} + +func makeManifest(slug string, pages []map[string]string) json.RawMessage { + body := map[string]any{ + "apiVersion": "gonext.io/v1", + "name": slug, + "version": "0.0.1", + "entry": "plugin.wasm", + } + if len(pages) > 0 { + ps := make([]map[string]string, len(pages)) + copy(ps, pages) + body["admin_pages"] = ps + } + b, _ := json.Marshal(body) + return b +} + +func TestList_OnlyActivePlugins(t *testing.T) { + plugins := []lifecycle.Plugin{ + { + Slug: "active-plugin", + State: lifecycle.StateActive, + Manifest: makeManifest("active-plugin", []map[string]string{{"slug": "dash", "label": "Dashboard"}}), + }, + { + Slug: "inactive-plugin", + State: lifecycle.StateInactive, + Manifest: makeManifest("inactive-plugin", []map[string]string{{"slug": "dash", "label": "Should not appear"}}), + }, + } + mux := newHarness(t, plugins) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugin-pages", nil) + req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{ + UserID: "u:1", Roles: []policy.Role{policy.RoleAdmin}, + })) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp listResponse + _ = json.NewDecoder(rec.Body).Decode(&resp) + if len(resp.Pages) != 1 { + t.Fatalf("expected 1 page, got %d: %+v", len(resp.Pages), resp.Pages) + } + if resp.Pages[0].Plugin != "active-plugin" || resp.Pages[0].Slug != "dash" { + t.Fatalf("unexpected page: %+v", resp.Pages[0]) + } +} + +func TestList_FlattensAcrossPlugins(t *testing.T) { + plugins := []lifecycle.Plugin{ + { + Slug: "a-plugin", + State: lifecycle.StateActive, + Manifest: makeManifest("a-plugin", []map[string]string{{"slug": "x", "label": "X"}, {"slug": "y", "label": "Y"}}), + }, + { + Slug: "b-plugin", + State: lifecycle.StateActive, + Manifest: makeManifest("b-plugin", []map[string]string{{"slug": "x", "label": "BX"}}), + }, + } + mux := newHarness(t, plugins) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugin-pages", nil) + req = req.WithContext(policy.WithPrincipal(req.Context(), policy.Principal{UserID: "u:1"})) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + var resp listResponse + _ = json.NewDecoder(rec.Body).Decode(&resp) + if len(resp.Pages) != 3 { + t.Fatalf("expected 3 pages, got %d: %+v", len(resp.Pages), resp.Pages) + } + // Sort guarantee: a-plugin/x, a-plugin/y, b-plugin/x. + if resp.Pages[0].Plugin != "a-plugin" || resp.Pages[2].Plugin != "b-plugin" { + t.Fatalf("unexpected ordering: %+v", resp.Pages) + } +} + +func TestList_AnonymousDenied(t *testing.T) { + mux := newHarness(t, nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/plugin-pages", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} diff --git a/migrations/000036_menus.down.sql b/migrations/000036_menus.down.sql new file mode 100644 index 00000000..840b4944 --- /dev/null +++ b/migrations/000036_menus.down.sql @@ -0,0 +1,10 @@ +-- 000036_menus.down.sql +-- +-- Drop order matters: menu_items references menus, so menu_items first. +DROP TRIGGER IF EXISTS menu_items_touch ON menu_items; +DROP TRIGGER IF EXISTS menus_touch ON menus; +DROP FUNCTION IF EXISTS menus_touch(); + +DROP INDEX IF EXISTS menu_items_menu_path_idx; +DROP TABLE IF EXISTS menu_items; +DROP TABLE IF EXISTS menus; diff --git a/migrations/000036_menus.up.sql b/migrations/000036_menus.up.sql new file mode 100644 index 00000000..517556fc --- /dev/null +++ b/migrations/000036_menus.up.sql @@ -0,0 +1,103 @@ +-- 000036_menus.up.sql +-- +-- Navigation menus — issue #54. Two tables, modelled after the +-- WordPress nav-menus surface but with an ltree-style path column so +-- nested menus (e.g. mega-menus with two levels of children) can be +-- pulled with a single range scan instead of N round-trips. +-- +-- A `menu` is a named container ("Primary", "Footer", "Mobile"); a +-- `menu_item` is a single link in it. Items carry a `path` of the form +-- "001", "001.002", "001.002.003" giving a stable in-tree position. The +-- renderer pulls every item for a menu_id ordered by path and rebuilds +-- the tree client-side; admin drag-to-reorder rewrites the path column +-- in a single transaction. +-- +-- The `path` is TEXT rather than the postgres ltree type because we +-- don't want every dev environment to install the contrib extension — +-- the access pattern (prefix-match for descendants, order-by for full +-- listing) works fine on a plain TEXT column with a btree index, and +-- the contention with the ltree operator surface is zero. + +CREATE TABLE menus ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Stable slug used by themes / the Navigation block to look up + -- the menu by name without hard-coding the UUID. Unique. + slug TEXT NOT NULL UNIQUE + CHECK (length(slug) > 0 AND length(slug) <= 64 + AND slug ~ '^[a-z0-9][a-z0-9_-]*$'), + -- Human label shown in the admin nav-menus index. + name TEXT NOT NULL + CHECK (length(name) > 0 AND length(name) <= 128), + -- Free-form metadata (theme-location hint, description). Not the + -- items list — those live in menu_items. + attrs JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE menus IS + 'Navigation menu containers. See packages/go/menus for the read/write path.'; + +CREATE TABLE menu_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + menu_id UUID NOT NULL + REFERENCES menus(id) ON DELETE CASCADE, + -- Dot-separated ltree-style path. Examples: + -- "001" — first root item + -- "001.001" — first child of "001" + -- "001.001.001" — first grandchild + -- Sort order is lexicographic over the column, which keeps siblings + -- next to each other and parents before children for a single + -- ORDER BY path. + path TEXT NOT NULL + CHECK (length(path) > 0 AND length(path) <= 256 + AND path ~ '^[0-9]{3}(\.[0-9]{3})*$'), + -- Display label rendered as the link's text. + label TEXT NOT NULL + CHECK (length(label) > 0 AND length(label) <= 256), + -- URL or path the link points at. Relative paths are resolved by + -- the renderer; external URLs pass through unchanged. + url TEXT NOT NULL DEFAULT '' + CHECK (length(url) <= 2048), + -- Optional reference to an internal object (post_id, page_id, + -- taxonomy_term_id). The Navigation block prefers this over `url` + -- so the link survives slug renames. + object_type TEXT + CHECK (object_type IS NULL OR + object_type = ANY(ARRAY['post','page','term','custom'])), + object_id UUID, + -- Free-form per-item metadata (icon, target=_blank, css_class). + attrs JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (menu_id, path) +); + +COMMENT ON TABLE menu_items IS + 'Individual links inside a navigation menu. path is an ltree-style dot path; ORDER BY path returns parents before children.'; + +-- The hot path is "give me every item in menu X ordered for render". +-- A composite index on (menu_id, path) supports both the list and the +-- prefix-match used by "load this subtree only". +CREATE INDEX menu_items_menu_path_idx ON menu_items (menu_id, path); + +-- updated_at trigger — inline rather than the shared helper for the +-- same reason the options migration inlined its own (no migration +-- ordering between this file and 000039_posts). +CREATE OR REPLACE FUNCTION menus_touch() RETURNS trigger AS $$ +BEGIN + NEW.updated_at := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER menus_touch + BEFORE UPDATE ON menus + FOR EACH ROW + EXECUTE FUNCTION menus_touch(); + +CREATE TRIGGER menu_items_touch + BEFORE UPDATE ON menu_items + FOR EACH ROW + EXECUTE FUNCTION menus_touch(); diff --git a/packages/go/blocks/navigation/navigation.go b/packages/go/blocks/navigation/navigation.go new file mode 100644 index 00000000..c43b780a --- /dev/null +++ b/packages/go/blocks/navigation/navigation.go @@ -0,0 +1,137 @@ +// Package navigation is the renderer integration for the core/navigation +// block. Issue #54. +// +// The block stores a menu_id attribute (UUID) and resolves it via the +// menus.Store at render time to produce a flat
    of items. The +// resolver is passed through the [render.Context] under the +// [ContextKeyMenuResolver] key so the renderer can stay decoupled from +// the persistence layer — tests inject an inline closure, production +// wires in [menus.Store.GetWithItemsBySlug] / [menus.Store.GetWithItems]. +package navigation + +import ( + "context" + "errors" + "fmt" + "html/template" + "strings" + + "github.com/google/uuid" + + "github.com/Singleton-Solution/GoNext/packages/go/blocks/render" + "github.com/Singleton-Solution/GoNext/packages/go/menus" +) + +// BlockType is the canonical wire name for the navigation block. +const BlockType = "core/navigation" + +// ContextKeyMenuResolver is the [render.Context] key under which a +// [MenuResolver] is expected to live. The walker boundary that mounts +// the block tree is responsible for stuffing one into the context. +const ContextKeyMenuResolver = "menuResolver" + +// MenuResolver is the closure the navigation renderer uses to fetch a +// menu's items. Implementations typically wrap a [menus.Store]. +type MenuResolver func(ctx context.Context, menuID uuid.UUID, slug string) (menus.MenuWithItems, error) + +// NewStoreResolver builds a [MenuResolver] that delegates to a +// [menus.Store]. Looks up by UUID when menuID is non-zero; falls back +// to lookup by slug otherwise. +func NewStoreResolver(store menus.Store) MenuResolver { + return func(ctx context.Context, menuID uuid.UUID, slug string) (menus.MenuWithItems, error) { + if menuID != uuid.Nil { + return store.GetWithItems(ctx, menuID) + } + if slug != "" { + return store.GetWithItemsBySlug(ctx, slug) + } + return menus.MenuWithItems{}, errors.New("navigation: block has neither menu_id nor menu_slug") + } +} + +// Register installs the navigation block renderer onto reg. +func Register(reg *render.Registry) error { + return reg.Register(BlockType, render.BlockSpec{ + Render: renderNav, + }) +} + +// render is the per-block renderer. It pulls the resolver out of the +// context, hands it the block's menu_id / menu_slug attribute, and +// emits a flat
      with the items sorted +// by path. If the resolver isn't installed or returns an error, the +// block emits an HTML comment carrying the failure cause — same "loud +// but non-fatal" contract as the rest of the core blocks. +func renderNav(block render.Block, _ template.HTML, ctx render.Context) (template.HTML, error) { + resolverV, ok := ctx[ContextKeyMenuResolver] + if !ok { + return template.HTML(""), nil + } + resolver, ok := resolverV.(MenuResolver) + if !ok { + return template.HTML(""), nil + } + + menuIDStr, _ := block.Attributes["menu_id"].(string) + menuSlug, _ := block.Attributes["menu_slug"].(string) + var menuID uuid.UUID + if menuIDStr != "" { + parsed, err := uuid.Parse(menuIDStr) + if err == nil { + menuID = parsed + } + } + if menuID == uuid.Nil && menuSlug == "" { + return template.HTML(""), nil + } + + bundle, err := resolver(rootContext(ctx), menuID, menuSlug) + if err != nil { + return template.HTML( + fmt.Sprintf("", htmlComment(err.Error())), + ), nil + } + if len(bundle.Items) == 0 { + return template.HTML("
        "), nil + } + + var b strings.Builder + b.WriteString(``) + return template.HTML(b.String()), nil +} + +// rootContext extracts a stdlib context.Context from the render context +// when callers stash one under "ctx"; falls back to context.Background. +// The render package's Context is plain map[string]any and doesn't +// carry a cancellation root by default, so the walker boundary stuffs +// one in when it cares about cancellation. +func rootContext(ctx render.Context) context.Context { + if v, ok := ctx["ctx"]; ok { + if c, ok := v.(context.Context); ok { + return c + } + } + return context.Background() +} + +// htmlComment scrubs "--" sequences out of a string so the comment +// stays well-formed HTML. +func htmlComment(s string) string { + return strings.ReplaceAll(s, "--", "- -") +} diff --git a/packages/go/blocks/navigation/navigation_test.go b/packages/go/blocks/navigation/navigation_test.go new file mode 100644 index 00000000..2ff268dd --- /dev/null +++ b/packages/go/blocks/navigation/navigation_test.go @@ -0,0 +1,81 @@ +package navigation + +import ( + "context" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/Singleton-Solution/GoNext/packages/go/blocks/render" + "github.com/Singleton-Solution/GoNext/packages/go/menus" +) + +func TestRenderNavigation_WithResolver(t *testing.T) { + store := menus.NewMemoryStore() + m, err := store.CreateMenu(context.Background(), menus.Menu{Slug: "primary", Name: "Primary"}) + if err != nil { + t.Fatalf("CreateMenu: %v", err) + } + if _, err := store.CreateItem(context.Background(), menus.MenuItem{ + MenuID: m.ID, Path: "001", Label: "Home", URL: "/", + }); err != nil { + t.Fatalf("CreateItem: %v", err) + } + if _, err := store.CreateItem(context.Background(), menus.MenuItem{ + MenuID: m.ID, Path: "002", Label: "About", URL: "/about", + }); err != nil { + t.Fatalf("CreateItem: %v", err) + } + + resolver := NewStoreResolver(store) + ctx := render.Context{ContextKeyMenuResolver: resolver} + block := render.Block{ + Attributes: map[string]any{"menu_id": m.ID.String()}, + } + out, err := renderNav(block, "", ctx) + if err != nil { + t.Fatalf("renderNav: %v", err) + } + got := string(out) + if !strings.Contains(got, `Home`) { + t.Fatalf("expected Home anchor: %s", got) + } + if !strings.Contains(got, `About`) { + t.Fatalf("expected About anchor: %s", got) + } +} + +func TestRenderNavigation_BySlug(t *testing.T) { + store := menus.NewMemoryStore() + m, _ := store.CreateMenu(context.Background(), menus.Menu{Slug: "footer", Name: "F"}) + _, _ = store.CreateItem(context.Background(), menus.MenuItem{ + MenuID: m.ID, Path: "001", Label: "Privacy", URL: "/privacy", + }) + + resolver := NewStoreResolver(store) + ctx := render.Context{ContextKeyMenuResolver: resolver} + block := render.Block{Attributes: map[string]any{"menu_slug": "footer"}} + out, _ := renderNav(block, "", ctx) + if !strings.Contains(string(out), "Privacy") { + t.Fatalf("expected Privacy in slug-resolved menu: %s", out) + } +} + +func TestRenderNavigation_NoResolver(t *testing.T) { + out, _ := renderNav(render.Block{Attributes: map[string]any{"menu_id": uuid.New().String()}}, "", render.Context{}) + if !strings.Contains(string(out), "no menuResolver") { + t.Fatalf("expected comment about missing resolver: %s", out) + } +} + +func TestRenderNavigation_MissingMenu(t *testing.T) { + store := menus.NewMemoryStore() + resolver := NewStoreResolver(store) + ctx := render.Context{ContextKeyMenuResolver: resolver} + block := render.Block{Attributes: map[string]any{"menu_id": uuid.New().String()}} + out, _ := renderNav(block, "", ctx) + if !strings.Contains(string(out), "resolve failed") { + t.Fatalf("expected resolve failed comment: %s", out) + } +} diff --git a/packages/go/menus/memory.go b/packages/go/menus/memory.go new file mode 100644 index 00000000..48fea120 --- /dev/null +++ b/packages/go/menus/memory.go @@ -0,0 +1,250 @@ +package menus + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "sync" + "time" + + "github.com/google/uuid" +) + +// MemoryStore is the in-process Store used by tests and the no-DB +// development fallthrough. Goroutine-safe. +type MemoryStore struct { + mu sync.RWMutex + menus map[uuid.UUID]Menu + // items keyed by menu_id then by item_id for O(1) lookup during + // reorder transactions. + items map[uuid.UUID]map[uuid.UUID]MenuItem + // now is the clock source — overridable in tests. + now func() time.Time +} + +// NewMemoryStore builds an empty in-memory Store ready for use. +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + menus: make(map[uuid.UUID]Menu), + items: make(map[uuid.UUID]map[uuid.UUID]MenuItem), + now: time.Now, + } +} + +// CreateMenu implements [Store.CreateMenu]. +func (s *MemoryStore) CreateMenu(_ context.Context, m Menu) (Menu, error) { + if err := validateMenu(m); err != nil { + return Menu{}, fmt.Errorf("%w: %s", ErrInvalidMenu, err.Error()) + } + s.mu.Lock() + defer s.mu.Unlock() + // Slug must be unique. + for _, existing := range s.menus { + if existing.Slug == m.Slug { + return Menu{}, fmt.Errorf("%w: slug already exists", ErrInvalidMenu) + } + } + if m.ID == uuid.Nil { + m.ID = uuid.New() + } + now := s.now().UTC() + m.CreatedAt = now + m.UpdatedAt = now + if len(m.Attrs) == 0 { + m.Attrs = json.RawMessage(`{}`) + } + s.menus[m.ID] = m + s.items[m.ID] = make(map[uuid.UUID]MenuItem) + return m, nil +} + +// GetMenu implements [Store.GetMenu]. +func (s *MemoryStore) GetMenu(_ context.Context, id uuid.UUID) (Menu, error) { + s.mu.RLock() + defer s.mu.RUnlock() + m, ok := s.menus[id] + if !ok { + return Menu{}, fmt.Errorf("%w: id=%s", ErrNotFound, id) + } + return m, nil +} + +// GetMenuBySlug implements [Store.GetMenuBySlug]. +func (s *MemoryStore) GetMenuBySlug(_ context.Context, slug string) (Menu, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, m := range s.menus { + if m.Slug == slug { + return m, nil + } + } + return Menu{}, fmt.Errorf("%w: slug=%s", ErrNotFound, slug) +} + +// UpdateMenu implements [Store.UpdateMenu]. Slug is immutable; the +// stored slug overrides any value the caller supplies. +func (s *MemoryStore) UpdateMenu(_ context.Context, m Menu) (Menu, error) { + s.mu.Lock() + defer s.mu.Unlock() + existing, ok := s.menus[m.ID] + if !ok { + return Menu{}, fmt.Errorf("%w: id=%s", ErrNotFound, m.ID) + } + // Pin slug to existing — the contract is "slug is immutable". + m.Slug = existing.Slug + m.CreatedAt = existing.CreatedAt + if err := validateMenu(m); err != nil { + return Menu{}, fmt.Errorf("%w: %s", ErrInvalidMenu, err.Error()) + } + m.UpdatedAt = s.now().UTC() + if len(m.Attrs) == 0 { + m.Attrs = json.RawMessage(`{}`) + } + s.menus[m.ID] = m + return m, nil +} + +// DeleteMenu implements [Store.DeleteMenu]. Cascades to items. +func (s *MemoryStore) DeleteMenu(_ context.Context, id uuid.UUID) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.menus, id) + delete(s.items, id) + return nil +} + +// ListMenus implements [Store.ListMenus]. Returned slice is sorted by +// name ascending. +func (s *MemoryStore) ListMenus(_ context.Context) ([]Menu, error) { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]Menu, 0, len(s.menus)) + for _, m := range s.menus { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +// CreateItem implements [Store.CreateItem]. +func (s *MemoryStore) CreateItem(_ context.Context, mi MenuItem) (MenuItem, error) { + if err := validateItem(mi); err != nil { + return MenuItem{}, fmt.Errorf("%w: %s", ErrInvalidItem, err.Error()) + } + s.mu.Lock() + defer s.mu.Unlock() + menuItems, ok := s.items[mi.MenuID] + if !ok { + return MenuItem{}, fmt.Errorf("%w: menu_id=%s", ErrNotFound, mi.MenuID) + } + // Path uniqueness within menu. + for _, existing := range menuItems { + if existing.Path == mi.Path { + return MenuItem{}, fmt.Errorf("%w: path %s already in use", ErrInvalidItem, mi.Path) + } + } + if mi.ID == uuid.Nil { + mi.ID = uuid.New() + } + now := s.now().UTC() + mi.CreatedAt = now + mi.UpdatedAt = now + if len(mi.Attrs) == 0 { + mi.Attrs = json.RawMessage(`{}`) + } + menuItems[mi.ID] = mi + return mi, nil +} + +// UpdateItem implements [Store.UpdateItem]. +func (s *MemoryStore) UpdateItem(_ context.Context, mi MenuItem) (MenuItem, error) { + s.mu.Lock() + defer s.mu.Unlock() + menuItems, ok := s.items[mi.MenuID] + if !ok { + return MenuItem{}, fmt.Errorf("%w: menu_id=%s", ErrNotFound, mi.MenuID) + } + existing, ok := menuItems[mi.ID] + if !ok { + return MenuItem{}, fmt.Errorf("%w: id=%s", ErrNotFound, mi.ID) + } + mi.CreatedAt = existing.CreatedAt + if err := validateItem(mi); err != nil { + return MenuItem{}, fmt.Errorf("%w: %s", ErrInvalidItem, err.Error()) + } + mi.UpdatedAt = s.now().UTC() + if len(mi.Attrs) == 0 { + mi.Attrs = json.RawMessage(`{}`) + } + menuItems[mi.ID] = mi + return mi, nil +} + +// DeleteItem implements [Store.DeleteItem]. +func (s *MemoryStore) DeleteItem(_ context.Context, id uuid.UUID) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, menuItems := range s.items { + if _, ok := menuItems[id]; ok { + delete(menuItems, id) + return nil + } + } + return nil +} + +// ReorderItems implements [Store.ReorderItems]. All-or-nothing under +// the store lock. +func (s *MemoryStore) ReorderItems(_ context.Context, menuID uuid.UUID, items []MenuItem) error { + s.mu.Lock() + defer s.mu.Unlock() + menuItems, ok := s.items[menuID] + if !ok { + return fmt.Errorf("%w: menu_id=%s", ErrNotFound, menuID) + } + // Validate every item before applying any change. + for _, mi := range items { + if _, ok := menuItems[mi.ID]; !ok { + return fmt.Errorf("%w: item id=%s", ErrNotFound, mi.ID) + } + if err := validateItem(mi); err != nil { + return fmt.Errorf("%w: %s", ErrInvalidItem, err.Error()) + } + } + now := s.now().UTC() + for _, mi := range items { + existing := menuItems[mi.ID] + existing.Path = mi.Path + existing.UpdatedAt = now + menuItems[mi.ID] = existing + } + return nil +} + +// GetWithItems implements [Store.GetWithItems]. +func (s *MemoryStore) GetWithItems(ctx context.Context, id uuid.UUID) (MenuWithItems, error) { + m, err := s.GetMenu(ctx, id) + if err != nil { + return MenuWithItems{}, err + } + s.mu.RLock() + defer s.mu.RUnlock() + out := MenuWithItems{Menu: m} + for _, mi := range s.items[id] { + out.Items = append(out.Items, mi) + } + sort.Slice(out.Items, func(i, j int) bool { + return out.Items[i].Path < out.Items[j].Path + }) + return out, nil +} + +// GetWithItemsBySlug implements [Store.GetWithItemsBySlug]. +func (s *MemoryStore) GetWithItemsBySlug(ctx context.Context, slug string) (MenuWithItems, error) { + m, err := s.GetMenuBySlug(ctx, slug) + if err != nil { + return MenuWithItems{}, err + } + return s.GetWithItems(ctx, m.ID) +} diff --git a/packages/go/menus/memory_test.go b/packages/go/menus/memory_test.go new file mode 100644 index 00000000..da4a20ae --- /dev/null +++ b/packages/go/menus/memory_test.go @@ -0,0 +1,136 @@ +package menus + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/google/uuid" +) + +func TestMemoryStore_MenuCRUD(t *testing.T) { + s := NewMemoryStore() + ctx := context.Background() + + created, err := s.CreateMenu(ctx, Menu{Slug: "primary", Name: "Primary"}) + if err != nil { + t.Fatalf("CreateMenu: %v", err) + } + if created.ID == uuid.Nil { + t.Fatalf("CreateMenu: id not assigned") + } + + got, err := s.GetMenuBySlug(ctx, "primary") + if err != nil || got.ID != created.ID { + t.Fatalf("GetMenuBySlug: got %+v err %v", got, err) + } + + created.Name = "Updated" + updated, err := s.UpdateMenu(ctx, created) + if err != nil || updated.Name != "Updated" { + t.Fatalf("UpdateMenu: %v", err) + } + + all, err := s.ListMenus(ctx) + if err != nil || len(all) != 1 { + t.Fatalf("ListMenus: got %d err %v", len(all), err) + } + + if err := s.DeleteMenu(ctx, created.ID); err != nil { + t.Fatalf("DeleteMenu: %v", err) + } + if _, err := s.GetMenu(ctx, created.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestMemoryStore_InvalidSlug(t *testing.T) { + s := NewMemoryStore() + _, err := s.CreateMenu(context.Background(), Menu{Slug: "Bad Slug!", Name: "x"}) + if !errors.Is(err, ErrInvalidMenu) { + t.Fatalf("expected ErrInvalidMenu, got %v", err) + } +} + +func TestMemoryStore_ItemsAndReorder(t *testing.T) { + s := NewMemoryStore() + ctx := context.Background() + m, err := s.CreateMenu(ctx, Menu{Slug: "footer", Name: "Footer"}) + if err != nil { + t.Fatalf("CreateMenu: %v", err) + } + item1, err := s.CreateItem(ctx, MenuItem{MenuID: m.ID, Path: "001", Label: "Home", URL: "/"}) + if err != nil { + t.Fatalf("CreateItem 1: %v", err) + } + item2, err := s.CreateItem(ctx, MenuItem{MenuID: m.ID, Path: "002", Label: "About", URL: "/about"}) + if err != nil { + t.Fatalf("CreateItem 2: %v", err) + } + + // Swap the two via reorder. + item1.Path = "002" + item2.Path = "001" + if err := s.ReorderItems(ctx, m.ID, []MenuItem{item1, item2}); err != nil { + t.Fatalf("ReorderItems: %v", err) + } + + bundle, err := s.GetWithItems(ctx, m.ID) + if err != nil { + t.Fatalf("GetWithItems: %v", err) + } + if len(bundle.Items) != 2 { + t.Fatalf("expected 2 items, got %d", len(bundle.Items)) + } + if bundle.Items[0].Label != "About" || bundle.Items[1].Label != "Home" { + t.Fatalf("reorder failed: %+v", bundle.Items) + } +} + +func TestMemoryStore_InvalidPath(t *testing.T) { + s := NewMemoryStore() + ctx := context.Background() + m, _ := s.CreateMenu(ctx, Menu{Slug: "x", Name: "X"}) + _, err := s.CreateItem(ctx, MenuItem{MenuID: m.ID, Path: "bad", Label: "x"}) + if !errors.Is(err, ErrInvalidItem) { + t.Fatalf("expected ErrInvalidItem for bad path, got %v", err) + } +} + +func TestMemoryStore_CascadeDelete(t *testing.T) { + s := NewMemoryStore() + ctx := context.Background() + m, _ := s.CreateMenu(ctx, Menu{Slug: "x", Name: "X"}) + _, _ = s.CreateItem(ctx, MenuItem{MenuID: m.ID, Path: "001", Label: "x"}) + if err := s.DeleteMenu(ctx, m.ID); err != nil { + t.Fatalf("DeleteMenu: %v", err) + } + // After delete, the items map for this menu is gone — GetWithItems + // returns ErrNotFound on the menu lookup. + if _, err := s.GetWithItems(ctx, m.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestMemoryStore_AttrsDefault(t *testing.T) { + s := NewMemoryStore() + m, _ := s.CreateMenu(context.Background(), Menu{Slug: "x", Name: "X"}) + if string(m.Attrs) != "{}" { + t.Fatalf("expected default attrs '{}', got %q", string(m.Attrs)) + } +} + +func TestMemoryStore_AttrsRoundTrip(t *testing.T) { + s := NewMemoryStore() + m, err := s.CreateMenu(context.Background(), Menu{ + Slug: "primary", Name: "P", + Attrs: json.RawMessage(`{"location":"header"}`), + }) + if err != nil { + t.Fatalf("CreateMenu: %v", err) + } + if !json.Valid(m.Attrs) { + t.Fatalf("attrs not valid JSON: %s", m.Attrs) + } +} diff --git a/packages/go/menus/model.go b/packages/go/menus/model.go new file mode 100644 index 00000000..2ac49556 --- /dev/null +++ b/packages/go/menus/model.go @@ -0,0 +1,157 @@ +// Package menus is the read/write path for navigation menus and their +// items. Issue #54. +// +// Two top-level types: [Menu] (a named container) and [MenuItem] (a +// single link). Items carry a dot-separated ltree-style [MenuItem.Path] +// so a full menu can be loaded in sort order with a single ORDER BY, +// and a subtree can be loaded with a prefix-match. +// +// Two concrete stores ship: +// +// - [MemoryStore]: backs tests and the no-DB development fallthrough. +// - [PgxStore]: parameterised SQL against the menus + menu_items +// tables (migration 000035). +// +// Renderer integration: the Navigation block resolves its `menu_id` +// attribute via [Store.GetWithItems] — one round trip, no N+1. +package menus + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" +) + +// Menu is a single named navigation container. +type Menu struct { + ID uuid.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Attrs json.RawMessage `json:"attrs"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MenuItem is a single link inside a [Menu]. [MenuItem.Path] is a +// dot-separated ltree-style ordering token; sort siblings and nest +// children by lexicographic compare. +type MenuItem struct { + ID uuid.UUID `json:"id"` + MenuID uuid.UUID `json:"menu_id"` + Path string `json:"path"` + Label string `json:"label"` + URL string `json:"url"` + ObjectType string `json:"object_type,omitempty"` + ObjectID *uuid.UUID `json:"object_id,omitempty"` + Attrs json.RawMessage `json:"attrs"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MenuWithItems bundles a menu and its items for the single-trip +// renderer fetch. +type MenuWithItems struct { + Menu Menu `json:"menu"` + Items []MenuItem `json:"items"` +} + +// Errors returned by Store implementations. +var ( + // ErrInvalidMenu is returned when [Menu] field validation fails + // (empty slug, slug regex mismatch, name too long). + ErrInvalidMenu = errors.New("menus: invalid menu") + // ErrInvalidItem is returned when [MenuItem] field validation + // fails (empty label, malformed path, oversize URL). + ErrInvalidItem = errors.New("menus: invalid item") + // ErrNotFound is returned by Get/Update/Delete when no row + // matches the requested ID or slug. + ErrNotFound = errors.New("menus: not found") +) + +// Store is the persistence contract. Implementations MUST be safe +// for concurrent use. +type Store interface { + // CreateMenu inserts a new menu. ID is assigned by the store. + CreateMenu(ctx context.Context, m Menu) (Menu, error) + // GetMenu fetches a menu by ID. + GetMenu(ctx context.Context, id uuid.UUID) (Menu, error) + // GetMenuBySlug resolves a menu by its stable slug. The + // Navigation block uses this when the author has pinned a slug + // rather than a UUID in the block attributes. + GetMenuBySlug(ctx context.Context, slug string) (Menu, error) + // UpdateMenu mutates name/attrs on an existing menu. Slug is + // immutable post-create — callers wanting to rename make a new + // menu and migrate items. + UpdateMenu(ctx context.Context, m Menu) (Menu, error) + // DeleteMenu removes a menu and (via ON DELETE CASCADE) every + // item belonging to it. + DeleteMenu(ctx context.Context, id uuid.UUID) error + // ListMenus returns every menu sorted by name. + ListMenus(ctx context.Context) ([]Menu, error) + + // CreateItem inserts a new menu item. Path must be supplied by + // the caller (typically derived from the drag-drop position). + CreateItem(ctx context.Context, mi MenuItem) (MenuItem, error) + // UpdateItem mutates label/url/object_type/object_id/attrs/path. + UpdateItem(ctx context.Context, mi MenuItem) (MenuItem, error) + // DeleteItem removes a single item by ID. Subtrees must be + // re-pathed by the caller before deletion. + DeleteItem(ctx context.Context, id uuid.UUID) error + // ReorderItems atomically rewrites the path of multiple items + // (the drag-drop "move + reorder" operation). All items must + // belong to the same menu_id. + ReorderItems(ctx context.Context, menuID uuid.UUID, items []MenuItem) error + + // GetWithItems is the single-trip renderer fetch. + GetWithItems(ctx context.Context, id uuid.UUID) (MenuWithItems, error) + // GetWithItemsBySlug is the slug-keyed variant. + GetWithItemsBySlug(ctx context.Context, slug string) (MenuWithItems, error) +} + +// validateMenu enforces the column CHECK rules in code so the memory +// store and the Postgres store fail the same way for the same input. +func validateMenu(m Menu) error { + if len(m.Slug) == 0 || len(m.Slug) > 64 { + return errors.New("menus: invalid menu: slug length out of range") + } + if !slugRe.MatchString(m.Slug) { + return errors.New("menus: invalid menu: slug must match ^[a-z0-9][a-z0-9_-]*$") + } + if len(m.Name) == 0 || len(m.Name) > 128 { + return errors.New("menus: invalid menu: name length out of range") + } + if len(m.Attrs) > 0 && !json.Valid(m.Attrs) { + return errors.New("menus: invalid menu: attrs not valid JSON") + } + return nil +} + +// validateItem enforces the menu_items CHECK rules in code. +func validateItem(mi MenuItem) error { + if len(mi.Label) == 0 || len(mi.Label) > 256 { + return errors.New("menus: invalid item: label length out of range") + } + if len(mi.Path) == 0 || len(mi.Path) > 256 { + return errors.New("menus: invalid item: path length out of range") + } + if !pathRe.MatchString(mi.Path) { + return errors.New("menus: invalid item: path must match ^[0-9]{3}(\\.[0-9]{3})*$") + } + if len(mi.URL) > 2048 { + return errors.New("menus: invalid item: url too long") + } + if mi.ObjectType != "" { + switch mi.ObjectType { + case "post", "page", "term", "custom": + default: + return errors.New("menus: invalid item: object_type must be one of post|page|term|custom") + } + } + if len(mi.Attrs) > 0 && !json.Valid(mi.Attrs) { + return errors.New("menus: invalid item: attrs not valid JSON") + } + return nil +} diff --git a/packages/go/menus/postgres.go b/packages/go/menus/postgres.go new file mode 100644 index 00000000..b4b2bc46 --- /dev/null +++ b/packages/go/menus/postgres.go @@ -0,0 +1,332 @@ +package menus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// Querier is the read/write surface shared by *pgxpool.Pool and pgx.Tx. +type Querier interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// TxBeginner is the optional transactional surface used by +// ReorderItems. A *pgxpool.Pool satisfies it. +type TxBeginner interface { + Querier + Begin(ctx context.Context) (pgx.Tx, error) +} + +// PgxStore is the production [Store] backed by Postgres. +type PgxStore struct { + db TxBeginner +} + +// NewPgxStore wraps a pool in the production Store. The pool is +// borrowed (not owned); the caller manages its lifecycle. +func NewPgxStore(db TxBeginner) *PgxStore { + return &PgxStore{db: db} +} + +const insertMenuSQL = ` +INSERT INTO menus (slug, name, attrs) +VALUES ($1, $2, $3) +RETURNING id, created_at, updated_at +` + +// CreateMenu implements [Store.CreateMenu]. +func (s *PgxStore) CreateMenu(ctx context.Context, m Menu) (Menu, error) { + if err := validateMenu(m); err != nil { + return Menu{}, fmt.Errorf("%w: %s", ErrInvalidMenu, err.Error()) + } + attrs := m.Attrs + if len(attrs) == 0 { + attrs = json.RawMessage(`{}`) + } + row := s.db.QueryRow(ctx, insertMenuSQL, m.Slug, m.Name, attrs) + if err := row.Scan(&m.ID, &m.CreatedAt, &m.UpdatedAt); err != nil { + return Menu{}, fmt.Errorf("menus: insert: %w", err) + } + m.Attrs = attrs + return m, nil +} + +const selectMenuByIDSQL = ` +SELECT id, slug, name, attrs, created_at, updated_at FROM menus WHERE id = $1 +` +const selectMenuBySlugSQL = ` +SELECT id, slug, name, attrs, created_at, updated_at FROM menus WHERE slug = $1 +` + +func scanMenu(row scannable) (Menu, error) { + var m Menu + if err := row.Scan(&m.ID, &m.Slug, &m.Name, &m.Attrs, &m.CreatedAt, &m.UpdatedAt); err != nil { + return Menu{}, err + } + return m, nil +} + +// GetMenu implements [Store.GetMenu]. +func (s *PgxStore) GetMenu(ctx context.Context, id uuid.UUID) (Menu, error) { + m, err := scanMenu(s.db.QueryRow(ctx, selectMenuByIDSQL, id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Menu{}, fmt.Errorf("%w: id=%s", ErrNotFound, id) + } + return Menu{}, fmt.Errorf("menus: get: %w", err) + } + return m, nil +} + +// GetMenuBySlug implements [Store.GetMenuBySlug]. +func (s *PgxStore) GetMenuBySlug(ctx context.Context, slug string) (Menu, error) { + m, err := scanMenu(s.db.QueryRow(ctx, selectMenuBySlugSQL, slug)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Menu{}, fmt.Errorf("%w: slug=%s", ErrNotFound, slug) + } + return Menu{}, fmt.Errorf("menus: get_by_slug: %w", err) + } + return m, nil +} + +const updateMenuSQL = ` +UPDATE menus SET name = $2, attrs = $3 WHERE id = $1 +RETURNING id, slug, name, attrs, created_at, updated_at +` + +// UpdateMenu implements [Store.UpdateMenu]. +func (s *PgxStore) UpdateMenu(ctx context.Context, m Menu) (Menu, error) { + if err := validateMenu(m); err != nil { + return Menu{}, fmt.Errorf("%w: %s", ErrInvalidMenu, err.Error()) + } + attrs := m.Attrs + if len(attrs) == 0 { + attrs = json.RawMessage(`{}`) + } + out, err := scanMenu(s.db.QueryRow(ctx, updateMenuSQL, m.ID, m.Name, attrs)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return Menu{}, fmt.Errorf("%w: id=%s", ErrNotFound, m.ID) + } + return Menu{}, fmt.Errorf("menus: update: %w", err) + } + return out, nil +} + +// DeleteMenu implements [Store.DeleteMenu]. ON DELETE CASCADE on +// menu_items.menu_id removes items automatically. +func (s *PgxStore) DeleteMenu(ctx context.Context, id uuid.UUID) error { + _, err := s.db.Exec(ctx, `DELETE FROM menus WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("menus: delete: %w", err) + } + return nil +} + +const listMenusSQL = ` +SELECT id, slug, name, attrs, created_at, updated_at +FROM menus ORDER BY name ASC +` + +// ListMenus implements [Store.ListMenus]. +func (s *PgxStore) ListMenus(ctx context.Context) ([]Menu, error) { + rows, err := s.db.Query(ctx, listMenusSQL) + if err != nil { + return nil, fmt.Errorf("menus: list: %w", err) + } + defer rows.Close() + var out []Menu + for rows.Next() { + m, err := scanMenu(rows) + if err != nil { + return nil, fmt.Errorf("menus: list_scan: %w", err) + } + out = append(out, m) + } + return out, rows.Err() +} + +const insertItemSQL = ` +INSERT INTO menu_items (menu_id, path, label, url, object_type, object_id, attrs) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, created_at, updated_at +` + +// CreateItem implements [Store.CreateItem]. +func (s *PgxStore) CreateItem(ctx context.Context, mi MenuItem) (MenuItem, error) { + if err := validateItem(mi); err != nil { + return MenuItem{}, fmt.Errorf("%w: %s", ErrInvalidItem, err.Error()) + } + attrs := mi.Attrs + if len(attrs) == 0 { + attrs = json.RawMessage(`{}`) + } + var objectType any + if mi.ObjectType != "" { + objectType = mi.ObjectType + } + var objectID any + if mi.ObjectID != nil { + objectID = *mi.ObjectID + } + row := s.db.QueryRow(ctx, insertItemSQL, + mi.MenuID, mi.Path, mi.Label, mi.URL, objectType, objectID, attrs) + if err := row.Scan(&mi.ID, &mi.CreatedAt, &mi.UpdatedAt); err != nil { + return MenuItem{}, fmt.Errorf("menus: insert_item: %w", err) + } + mi.Attrs = attrs + return mi, nil +} + +const updateItemSQL = ` +UPDATE menu_items +SET path = $2, label = $3, url = $4, object_type = $5, object_id = $6, attrs = $7 +WHERE id = $1 +RETURNING id, menu_id, path, label, url, object_type, object_id, attrs, created_at, updated_at +` + +func scanItem(row scannable) (MenuItem, error) { + var mi MenuItem + var objectType *string + var objectID *uuid.UUID + if err := row.Scan(&mi.ID, &mi.MenuID, &mi.Path, &mi.Label, &mi.URL, + &objectType, &objectID, &mi.Attrs, &mi.CreatedAt, &mi.UpdatedAt); err != nil { + return MenuItem{}, err + } + if objectType != nil { + mi.ObjectType = *objectType + } + mi.ObjectID = objectID + return mi, nil +} + +// UpdateItem implements [Store.UpdateItem]. +func (s *PgxStore) UpdateItem(ctx context.Context, mi MenuItem) (MenuItem, error) { + if err := validateItem(mi); err != nil { + return MenuItem{}, fmt.Errorf("%w: %s", ErrInvalidItem, err.Error()) + } + attrs := mi.Attrs + if len(attrs) == 0 { + attrs = json.RawMessage(`{}`) + } + var objectType any + if mi.ObjectType != "" { + objectType = mi.ObjectType + } + var objectID any + if mi.ObjectID != nil { + objectID = *mi.ObjectID + } + out, err := scanItem(s.db.QueryRow(ctx, updateItemSQL, + mi.ID, mi.Path, mi.Label, mi.URL, objectType, objectID, attrs)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return MenuItem{}, fmt.Errorf("%w: id=%s", ErrNotFound, mi.ID) + } + return MenuItem{}, fmt.Errorf("menus: update_item: %w", err) + } + return out, nil +} + +// DeleteItem implements [Store.DeleteItem]. +func (s *PgxStore) DeleteItem(ctx context.Context, id uuid.UUID) error { + _, err := s.db.Exec(ctx, `DELETE FROM menu_items WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("menus: delete_item: %w", err) + } + return nil +} + +// ReorderItems implements [Store.ReorderItems] as a single transaction. +// +// The unique (menu_id, path) constraint means we can't simply UPDATE +// each row in place if any two new paths conflict with old ones — the +// constraint check fires per-statement. We side-step by first prefixing +// every path with a sentinel character, then writing the real target +// paths. The sentinel form fails the regex CHECK, so we use the +// DEFERRABLE INITIALLY DEFERRED CHECK constraint approach via two-stage +// values: assign each item a temporary "001000."-style path inside +// the bounds of the regex, then move them to their final paths. +func (s *PgxStore) ReorderItems(ctx context.Context, menuID uuid.UUID, items []MenuItem) error { + for _, mi := range items { + if err := validateItem(mi); err != nil { + return fmt.Errorf("%w: %s", ErrInvalidItem, err.Error()) + } + } + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("menus: reorder begin: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + // Stage 1: park every item under a unique scratch path that won't + // collide with anything in the user's payload. We use 999.NNN + // suffixes — well above the realistic per-menu item count. + for i, mi := range items { + scratch := fmt.Sprintf("999.%03d", i+1) + if _, err := tx.Exec(ctx, + `UPDATE menu_items SET path = $2 WHERE id = $1 AND menu_id = $3`, + mi.ID, scratch, menuID); err != nil { + return fmt.Errorf("menus: reorder stage1: %w", err) + } + } + // Stage 2: write the real target paths. + for _, mi := range items { + if _, err := tx.Exec(ctx, + `UPDATE menu_items SET path = $2 WHERE id = $1 AND menu_id = $3`, + mi.ID, mi.Path, menuID); err != nil { + return fmt.Errorf("menus: reorder stage2: %w", err) + } + } + return tx.Commit(ctx) +} + +const listItemsSQL = ` +SELECT id, menu_id, path, label, url, object_type, object_id, attrs, created_at, updated_at +FROM menu_items WHERE menu_id = $1 ORDER BY path ASC +` + +// GetWithItems implements [Store.GetWithItems]. +func (s *PgxStore) GetWithItems(ctx context.Context, id uuid.UUID) (MenuWithItems, error) { + m, err := s.GetMenu(ctx, id) + if err != nil { + return MenuWithItems{}, err + } + rows, err := s.db.Query(ctx, listItemsSQL, id) + if err != nil { + return MenuWithItems{}, fmt.Errorf("menus: list_items: %w", err) + } + defer rows.Close() + out := MenuWithItems{Menu: m} + for rows.Next() { + mi, err := scanItem(rows) + if err != nil { + return MenuWithItems{}, fmt.Errorf("menus: list_items_scan: %w", err) + } + out.Items = append(out.Items, mi) + } + return out, rows.Err() +} + +// GetWithItemsBySlug implements [Store.GetWithItemsBySlug]. +func (s *PgxStore) GetWithItemsBySlug(ctx context.Context, slug string) (MenuWithItems, error) { + m, err := s.GetMenuBySlug(ctx, slug) + if err != nil { + return MenuWithItems{}, err + } + return s.GetWithItems(ctx, m.ID) +} + +// scannable is the subset of pgx.Row / pgx.Rows that Scan needs. +type scannable interface { + Scan(dest ...any) error +} diff --git a/packages/go/menus/regex.go b/packages/go/menus/regex.go new file mode 100644 index 00000000..209c2ddd --- /dev/null +++ b/packages/go/menus/regex.go @@ -0,0 +1,10 @@ +package menus + +import "regexp" + +// Package-level regexes compiled once at init. Matching the column +// CHECK rules from migration 000035. +var ( + slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + pathRe = regexp.MustCompile(`^[0-9]{3}(\.[0-9]{3})*$`) +) diff --git a/packages/go/plugins/manifest/manifest.go b/packages/go/plugins/manifest/manifest.go index a3d595fd..b052bc62 100644 --- a/packages/go/plugins/manifest/manifest.go +++ b/packages/go/plugins/manifest/manifest.go @@ -98,6 +98,14 @@ type Manifest struct { // in v1. Signature string `json:"signature,omitempty"` + // AdminPages declares admin UI pages the plugin contributes. + // Issue #228. The admin shell walks every active plugin's + // AdminPages and renders one Sidebar entry per declared page in + // the "Plugins" section. The plugin's frontend host lazy-loads + // the page module on first visit. Optional — plugins with no + // admin surface omit the field. + AdminPages []AdminPage `json:"admin_pages,omitempty"` + // Storage declares persistent-storage budgets for the plugin. Today // only the KV namespace is described; the field is optional and // omitted manifests get whatever default the operator policy @@ -130,6 +138,22 @@ type Flags struct { // RegisterFilter. Default false; legacy plugins keep the per-item // contract. ApplyFiltersBatch bool `json:"apply_filters_batch,omitempty"` +// AdminPage describes one admin sidebar entry a plugin contributes +// under the "Plugins" group. Issue #228. +type AdminPage struct { + // Slug is the per-plugin page identifier, URL-safe. Combined with + // the plugin slug to form the route /plugins/{plugin}/{slug}. + Slug string `json:"slug"` + // Label is the human-readable text shown in the sidebar. + Label string `json:"label"` + // Icon is the optional Lucide icon name (e.g. "Settings", + // "BarChart3"). The admin shell falls back to a generic plug icon + // when this is missing or unknown. + Icon string `json:"icon,omitempty"` + // Capability is the optional capability the viewer must hold to + // see this page. Empty means "visible to anyone with access to + // the Plugins section". + Capability string `json:"capability,omitempty"` } // Hooks is the actions/filters split. Both arrays are optional; an diff --git a/packages/go/plugins/manifest/schema.json b/packages/go/plugins/manifest/schema.json index 1a5d7d77..976e6612 100644 --- a/packages/go/plugins/manifest/schema.json +++ b/packages/go/plugins/manifest/schema.json @@ -112,6 +112,39 @@ "type": "string", "pattern": "^[0-9a-f]{128}$" }, + "admin_pages": { + "description": "Admin UI pages the plugin contributes. Each entry creates an entry under the Plugins section of the admin sidebar; clicking it loads the plugin's frontend at /plugins/{slug}/{page-slug}. The host walks active plugins at runtime and renders one Sidebar entry per declared page, gated by the optional capability.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["slug", "label"], + "properties": { + "slug": { + "description": "URL-safe slug for this page. Combined with the plugin slug to form the route /plugins/{plugin}/{page-slug}.", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,40}$" + }, + "label": { + "description": "Display label shown in the sidebar.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "icon": { + "description": "Optional Lucide icon name (e.g. \"Settings\", \"BarChart3\"). The admin shell falls back to a generic plug icon when missing or unknown.", + "type": "string", + "maxLength": 32 + }, + "capability": { + "description": "Optional capability the operator must hold to see this page. When absent the page is shown to anyone who can see the Plugins section.", + "type": "string", + "pattern": "^[a-z][a-z0-9_]*(?:\\.[a-z][a-z0-9_]*)*$" + } + } + } + }, "storage": { "description": "Optional declarative budgets for the plugin's persistent-storage surface. Only the kv namespace is described today; future fields (db, blob) will land here.", "type": "object", diff --git a/packages/go/settings/privacy.go b/packages/go/settings/privacy.go new file mode 100644 index 00000000..4910b442 --- /dev/null +++ b/packages/go/settings/privacy.go @@ -0,0 +1,114 @@ +package settings + +import ( + "encoding/json" + + "github.com/Singleton-Solution/GoNext/packages/go/policy" +) + +// Privacy setting keys — issue #225. These back the Settings → Privacy +// admin form (cookie policy URL + text, retention windows for the +// audit / sessions / login-attempts streams, and the GDPR self-service +// kill switch). Mirrors the WordPress "privacy settings" surface, but +// surfaces retention as first-class typed values instead of free-form +// help text. +const ( + // PrivacyCookiePolicyURL is the absolute URL of the site's cookie + // policy. Themes link to it from the consent banner and footer. + PrivacyCookiePolicyURL = "core.privacy.cookie_policy_url" + // PrivacyCookiePolicyText is the human-readable text shown in the + // cookie consent banner. Plain text; themes wrap it. + PrivacyCookiePolicyText = "core.privacy.cookie_policy_text" + + // Retention windows are expressed in days. A zero value means + // "retain forever"; the retention job treats anything > 0 as the + // max-age cutoff. + PrivacyRetentionAuditDays = "core.privacy.retention.audit_days" + PrivacyRetentionSessionsDays = "core.privacy.retention.sessions_days" + PrivacyRetentionLoginAttemptsDays = "core.privacy.retention.login_attempts_days" + + // PrivacyAllowGDPRSelfService gates the public + // /api/v1/account/data/export endpoint. When false, the endpoint + // returns 403 and the admin UI hides the user-facing data export + // affordance. + PrivacyAllowGDPRSelfService = "core.privacy.allow_gdpr_self_service" +) + +// PrivacySettings returns the privacy-group settings registered onto +// the core registry. Kept separate from [CoreSettings] so the group +// can grow without touching the existing core seed list. +func PrivacySettings() []Setting { + return []Setting{ + { + Key: PrivacyCookiePolicyURL, + Description: "Absolute URL of the site's cookie policy. Themes link to it from the consent banner and footer.", + Type: SettingTypeString, + Schema: json.RawMessage(`{"type":"string","maxLength":2048}`), + Default: "", + Autoload: true, + Group: GroupPrivacy, + RequiresCapability: policy.CapManageOptions, + }, + { + Key: PrivacyCookiePolicyText, + Description: "Human-readable text shown in the cookie consent banner.", + Type: SettingTypeString, + Schema: json.RawMessage(`{"type":"string","maxLength":4096}`), + Default: "This site uses cookies to keep you signed in and remember your preferences.", + Autoload: true, + Group: GroupPrivacy, + RequiresCapability: policy.CapManageOptions, + }, + { + Key: PrivacyRetentionAuditDays, + Description: "Number of days to retain audit-log entries. 0 means retain indefinitely.", + Type: SettingTypeInt, + Schema: json.RawMessage(`{"type":"integer","minimum":0,"maximum":3650}`), + Default: float64(365), + Autoload: true, + Group: GroupPrivacy, + RequiresCapability: policy.CapManageOptions, + }, + { + Key: PrivacyRetentionSessionsDays, + Description: "Number of days to retain expired session records. 0 means retain indefinitely.", + Type: SettingTypeInt, + Schema: json.RawMessage(`{"type":"integer","minimum":0,"maximum":3650}`), + Default: float64(30), + Autoload: true, + Group: GroupPrivacy, + RequiresCapability: policy.CapManageOptions, + }, + { + Key: PrivacyRetentionLoginAttemptsDays, + Description: "Number of days to retain failed-login attempt records. 0 means retain indefinitely.", + Type: SettingTypeInt, + Schema: json.RawMessage(`{"type":"integer","minimum":0,"maximum":3650}`), + Default: float64(90), + Autoload: true, + Group: GroupPrivacy, + RequiresCapability: policy.CapManageOptions, + }, + { + Key: PrivacyAllowGDPRSelfService, + Description: "Whether users may export their personal data via /api/v1/account/data/export. When false, the endpoint returns 403.", + Type: SettingTypeBool, + Schema: json.RawMessage(`{"type":"boolean"}`), + Default: true, + Autoload: true, + Group: GroupPrivacy, + RequiresCapability: policy.CapManageOptions, + }, + } +} + +// RegisterPrivacy adds the privacy-group settings to reg. Call after +// [RegisterCore]. +func RegisterPrivacy(reg *Registry) error { + for _, s := range PrivacySettings() { + if err := reg.Register(s); err != nil { + return err + } + } + return nil +} diff --git a/packages/go/settings/privacy_test.go b/packages/go/settings/privacy_test.go new file mode 100644 index 00000000..c180cbe2 --- /dev/null +++ b/packages/go/settings/privacy_test.go @@ -0,0 +1,54 @@ +package settings + +import ( + "context" + "testing" +) + +func TestRegisterPrivacy_AllRegister(t *testing.T) { + reg := NewRegistry() + if err := RegisterPrivacy(reg); err != nil { + t.Fatalf("RegisterPrivacy: %v", err) + } + store := NewMemoryStore(reg) + for _, s := range PrivacySettings() { + v, err := store.Read(context.Background(), s.Key) + if err != nil { + t.Errorf("Read %s: %v", s.Key, err) + continue + } + if v == nil && s.Default != nil { + t.Errorf("expected default for %s, got nil", s.Key) + } + } +} + +func TestPrivacyRetentionAcceptsInteger(t *testing.T) { + reg := NewRegistry() + if err := RegisterPrivacy(reg); err != nil { + t.Fatalf("RegisterPrivacy: %v", err) + } + store := NewMemoryStore(reg) + // 0 means "retain forever" — schema allows it. + if err := store.Write(context.Background(), PrivacyRetentionAuditDays, float64(0)); err != nil { + t.Fatalf("Write 0: %v", err) + } + if err := store.Write(context.Background(), PrivacyRetentionAuditDays, float64(180)); err != nil { + t.Fatalf("Write 180: %v", err) + } +} + +func TestPrivacyAllowGDPRSelfServiceBoolean(t *testing.T) { + reg := NewRegistry() + if err := RegisterPrivacy(reg); err != nil { + t.Fatalf("RegisterPrivacy: %v", err) + } + store := NewMemoryStore(reg) + if err := store.Write(context.Background(), PrivacyAllowGDPRSelfService, false); err != nil { + t.Fatalf("Write false: %v", err) + } + v, _ := store.Read(context.Background(), PrivacyAllowGDPRSelfService) + if v.(bool) != false { + t.Fatalf("expected false, got %v", v) + } +}