diff --git a/Makefile b/Makefile index 7b5d97f22..e0e22817b 100644 --- a/Makefile +++ b/Makefile @@ -205,13 +205,14 @@ generate-license: go run scripts/gen-license-features.go .PHONY: generate-api -generate-api: +generate-api: internal/server/openapi_embed.yaml @if [ ! -x "$(HOME)/go/bin/oapi-codegen" ]; then \ echo "installing oapi-codegen..."; \ go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest; \ fi $(HOME)/go/bin/oapi-codegen --config api/oapi-codegen.yaml api/openapi.yaml @echo "generated internal/server/api/server.gen.go" + @echo "refreshed internal/server/openapi_embed.yaml (kept in sync with api/openapi.yaml)" # The OpenAPI spec is also embedded into the binary so the /api/v1/openapi.yaml # and /docs routes can serve it air-gap-clean. go:embed cannot follow paths diff --git a/api/error_codes.yaml b/api/error_codes.yaml index 33f164b2b..38fb22fe7 100644 --- a/api/error_codes.yaml +++ b/api/error_codes.yaml @@ -184,6 +184,18 @@ errors: properties: field: {type: string} + - code: validation.invalid_body + http_status: 400 + fault: client + retryable: false + description: The request body is not valid JSON or does not match the expected shape + + - code: validation.invalid_value + http_status: 400 + fault: client + retryable: false + description: A field carries a value outside its allowed set (e.g. an unknown enum) + - code: validation.field_format http_status: 400 fault: client diff --git a/api/openapi.yaml b/api/openapi.yaml index 03eb65c15..b6aa88b6c 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -120,6 +120,55 @@ paths: application/json: schema: {$ref: '#/components/schemas/ErrorEnvelope'} + /api/v1/users/me/preferences: + get: + operationId: getUsersMePreferences + summary: Return the calling user's UI preferences + description: > + Per-user UI preferences (e.g. the /hosts grid-vs-table default). + Self-scoped — any authenticated identity reads its own; no special + permission. Unset keys are simply absent (the client falls back to + its defaults). Spec system-user-preferences + api-user-preferences. + responses: + '200': + description: The caller's stored preferences + content: + application/json: + schema: {$ref: '#/components/schemas/UserPreferences'} + '401': + description: No valid session or bearer + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + patch: + operationId: patchUsersMePreferences + summary: Merge a partial update into the calling user's UI preferences + description: > + Shallow-merges the provided keys into the caller's stored + preferences and returns the merged result. Only the keys present in + the body are changed; omitted keys are retained. Self-scoped. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UserPreferences'} + responses: + '200': + description: The merged preferences + content: + application/json: + schema: {$ref: '#/components/schemas/UserPreferences'} + '400': + description: Malformed body + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + '401': + description: No valid session or bearer + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorEnvelope'} + /api/v1/auth/mfa:enroll: post: operationId: postAuthMFAEnroll @@ -3771,6 +3820,23 @@ components: email: {type: string} role: {type: string} + # Per-user UI preferences (system-user-preferences). Every field is + # optional: GET returns only the keys the user has set, and PATCH treats + # the body as a partial (present keys merge, omitted keys are retained). + # additionalProperties:false governs the valid key set — adding a knob is + # a deliberate contract change here, not arbitrary client-written JSON. + UserPreferences: + type: object + additionalProperties: false + properties: + # The /hosts page default view when the URL has no ?view= override. + hosts_view_default: {type: string, enum: [table, cards]} + density: {type: string, enum: [comfortable, compact]} + accent_color: {type: string, enum: [info, ok, brand2]} + landing_page: {type: string, enum: [hosts, dashboard, reports]} + date_format: {type: string, enum: [us12, iso24, long24]} + reduce_motion: {type: boolean} + AuthMFAEnrollResponse: type: object required: [provisioning_uri] @@ -4432,6 +4498,12 @@ components: # when no compliance check has ever run against the host. # Surface for the operator dashboard's "last scan X ago" cell. last_scan_at: {type: string, format: date-time, nullable: true} + # v1.6.0 — id of the newest COMPLETED scan_run for this host. Drives + # the host card's "view report" affordance, which links to + # /scans/{latest_scan_id} (the scan:read-gated detail page). NULL + # when the host has no completed scan yet (icon hidden). Spec + # api-hosts C-13. + latest_scan_id: {type: string, format: uuid, nullable: true} liveness: allOf: [{$ref: '#/components/schemas/HostLiveness'}] nullable: true diff --git a/frontend/src/api/host-filtering.ts b/frontend/src/api/host-filtering.ts new file mode 100644 index 000000000..1d3ef6bf6 --- /dev/null +++ b/frontend/src/api/host-filtering.ts @@ -0,0 +1,134 @@ +// Pure grouping + filtering helpers for the /hosts fleet view. Kept +// separate from HostsListPage so the behavior is unit-testable in +// isolation (the page wiring stays a thin shell over these functions). +// Spec: frontend-hosts-list v1.7.0 C-10 (grouping) + C-11 (filters). + +import type { DevHost, MonitoringBand } from './host-view-model'; + +// ─── Grouping ──────────────────────────────────────────────────────────── + +// Drop 'team': a host has no team/owner field (see api-hosts HostListItem), +// so only None/Status/OS are backed by real data. +export type GroupKey = 'none' | 'status' | 'os'; + +export interface HostGroup { + key: string; + label: string; + hosts: DevHost[]; +} + +// Worst-first, mirroring the page's default down-first ordering so the +// most actionable groups surface at the top. +const STATUS_ORDER: MonitoringBand[] = [ + 'critical', + 'down', + 'degraded', + 'online', + 'maintenance', + 'unknown', +]; + +const STATUS_LABEL: Record = { + online: 'Online', + degraded: 'Degraded', + critical: 'Critical', + down: 'Down', + maintenance: 'Maintenance', + unknown: 'Unknown', +}; + +export function statusLabel(band: MonitoringBand): string { + return STATUS_LABEL[band] ?? 'Unknown'; +} + +// groupHosts partitions the (already sorted/filtered) host list into +// labelled sections. group='none' returns a single anonymous group so the +// renderer can treat both paths uniformly. Empty groups are omitted. +export function groupHosts(hosts: DevHost[], group: GroupKey): HostGroup[] { + if (group === 'none') { + return [{ key: 'none', label: '', hosts }]; + } + if (group === 'status') { + return STATUS_ORDER.map((band) => ({ + key: band, + label: statusLabel(band), + hosts: hosts.filter((h) => h.monitoring === band), + })).filter((g) => g.hosts.length > 0); + } + // group === 'os' — alphabetical, with the catch-all "Unknown" last. + const byOs = new Map(); + for (const h of hosts) { + const key = h.os || 'Unknown'; + (byOs.get(key) ?? byOs.set(key, []).get(key)!).push(h); + } + return [...byOs.entries()] + .sort(([a], [b]) => { + if (a === 'Unknown') return 1; + if (b === 'Unknown') return -1; + return a.localeCompare(b); + }) + .map(([key, hs]) => ({ key, label: key, hosts: hs })); +} + +// ─── Filtering ─────────────────────────────────────────────────────────── + +export type TierFilter = 'crit' | 'warn' | 'ok' | 'none'; + +export interface HostFilters { + status: string[]; // MonitoringBand values + os: string[]; // osDisplayLabel values (host.os) + tier: string[]; // TierFilter values +} + +const TIER_LABEL: Record = { + crit: 'Critical (<40%)', + warn: 'Warning (40-80%)', + ok: 'Compliant (>=80%)', + none: 'No scan data', +}; + +export function tierLabel(t: TierFilter): string { + return TIER_LABEL[t] ?? t; +} + +// hostComplianceTier buckets a host's compliance score. A never-scanned +// host (compliance null) is its own 'none' bucket rather than 'crit', so +// "no data" and "actually failing" stay distinguishable in the filter. +export function hostComplianceTier(h: DevHost): TierFilter { + if (h.compliance == null) return 'none'; + if (h.compliance < 40) return 'crit'; + if (h.compliance < 80) return 'warn'; + return 'ok'; +} + +function csv(v: string | undefined): string[] { + if (!v) return []; + return v + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} + +export function parseHostFilters(search: { + status?: string; + os?: string; + tier?: string; +}): HostFilters { + return { status: csv(search.status), os: csv(search.os), tier: csv(search.tier) }; +} + +// applyHostFilters keeps a host only when it matches EVERY active +// dimension (AND across dimensions, OR within a dimension). An empty +// dimension imposes no constraint. +export function applyHostFilters(hosts: DevHost[], f: HostFilters): DevHost[] { + return hosts.filter((h) => { + if (f.status.length && !f.status.includes(h.monitoring)) return false; + if (f.os.length && !f.os.includes(h.os || 'Unknown')) return false; + if (f.tier.length && !f.tier.includes(hostComplianceTier(h))) return false; + return true; + }); +} + +export function activeFilterCount(f: HostFilters): number { + return f.status.length + f.os.length + f.tier.length; +} diff --git a/frontend/src/api/host-view-model.ts b/frontend/src/api/host-view-model.ts index 903083eb9..129a48c04 100644 --- a/frontend/src/api/host-view-model.ts +++ b/frontend/src/api/host-view-model.ts @@ -41,6 +41,13 @@ export interface DevHost { // renders "—" in that case rather than the misleading "0m ago". lastCheckMinutes: number | null; lastScan: string; // "Xh ago" or "Xm ago" + /** + * id of the newest completed scan_run, from the list endpoint's + * latest_scan_id. null when the host has no completed scan — the card's + * "view report" affordance is hidden in that case. Spec + * frontend-hosts-list AC-24, links to /scans/{latestScanId}. + */ + latestScanId: string | null; } export type DeltaTier = 'crit' | 'warn' | 'ok' | 'neutral'; diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 339bca148..382c9c703 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -92,6 +92,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/users/me/preferences": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Return the calling user's UI preferences + * @description Per-user UI preferences (e.g. the /hosts grid-vs-table default). Self-scoped — any authenticated identity reads its own; no special permission. Unset keys are simply absent (the client falls back to its defaults). Spec system-user-preferences + api-user-preferences. + */ + get: operations["getUsersMePreferences"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Merge a partial update into the calling user's UI preferences + * @description Shallow-merges the provided keys into the caller's stored preferences and returns the merged result. Only the keys present in the body are changed; omitted keys are retained. Self-scoped. + */ + patch: operations["patchUsersMePreferences"]; + trace?: never; + }; "/api/v1/auth/mfa:enroll": { parameters: { query?: never; @@ -2410,6 +2434,19 @@ export interface components { email: string; role: string; }; + UserPreferences: { + /** @enum {string} */ + hosts_view_default?: "table" | "cards"; + /** @enum {string} */ + density?: "comfortable" | "compact"; + /** @enum {string} */ + accent_color?: "info" | "ok" | "brand2"; + /** @enum {string} */ + landing_page?: "hosts" | "dashboard" | "reports"; + /** @enum {string} */ + date_format?: "us12" | "iso24" | "long24"; + reduce_motion?: boolean; + }; AuthMFAEnrollResponse: { provisioning_uri: string; }; @@ -2927,6 +2964,8 @@ export interface components { check_priority?: number; /** Format: date-time */ last_scan_at?: string | null; + /** Format: uuid */ + latest_scan_id?: string | null; /** @description Null when no liveness probe has ever run against this host. */ liveness?: components["schemas"]["HostLiveness"] | null; /** @description Null when the host has no host_rule_state rows (never scanned). */ @@ -4103,6 +4142,77 @@ export interface operations { }; }; }; + getUsersMePreferences: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The caller's stored preferences */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPreferences"]; + }; + }; + /** @description No valid session or bearer */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; + patchUsersMePreferences: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserPreferences"]; + }; + }; + responses: { + /** @description The merged preferences */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserPreferences"]; + }; + }; + /** @description Malformed body */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description No valid session or bearer */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + }; + }; postAuthMFAEnroll: { parameters: { query?: never; diff --git a/frontend/src/components/shell/AppFrame.tsx b/frontend/src/components/shell/AppFrame.tsx index 35151c1cd..984806588 100644 --- a/frontend/src/components/shell/AppFrame.tsx +++ b/frontend/src/components/shell/AppFrame.tsx @@ -1,13 +1,23 @@ +import { useEffect } from 'react'; import { Outlet } from '@tanstack/react-router'; import { Sidebar } from './Sidebar'; import { TopBar } from './TopBar'; import { ErrorBoundary } from './ErrorBoundary'; +import { usePreferencesStore } from '@/store/usePreferencesStore'; // AppFrame — the persistent shell that wraps every authenticated route. // // Spec: frontend-foundation C-08, C-09, AC-10. export function AppFrame() { + // Reconcile per-user UI preferences with the server once the + // authenticated shell mounts (system-user-preferences). Best-effort: a + // failed fetch leaves the localStorage-cached values in place. + const hydratePreferences = usePreferencesStore((s) => s.hydrateFromServer); + useEffect(() => { + void hydratePreferences(); + }, [hydratePreferences]); + return (
setCrumbs([]); }, [setCrumbs]); - const view: 'table' | 'cards' = search.view === 'table' ? 'table' : 'cards'; - const group = search.group ?? 'none'; + // View: the URL ?view= wins (shareable / refresh-stable, C-04); absent + // that, fall back to the user's server-persisted default + // (system-user-preferences). Toggling sets BOTH so the choice "becomes + // the default until changed". + const hostsViewDefault = usePreferencesStore((s) => s.hostsViewDefault); + const setHostsViewDefault = usePreferencesStore((s) => s.setHostsViewDefault); + const view: 'table' | 'cards' = + search.view === 'table' || search.view === 'cards' ? search.view : hostsViewDefault; + const group: GroupKey = search.group === 'status' || search.group === 'os' ? search.group : 'none'; const query = (search.q ?? '').trim().toLowerCase(); + const filters: HostFilters = useMemo( + () => parseHostFilters({ status: search.status, os: search.os, tier: search.tier }), + [search.status, search.os, search.tier], + ); const hostsQuery = useQuery({ queryKey: ['hosts', search.env, search.tag], @@ -159,12 +190,16 @@ export function HostsListPage() { const hosts: DevHost[] = (hostsQuery.data ?? []).map(apiHostToDev); const visible = useMemo(() => { - if (!query) return hosts; - return hosts.filter((h) => { - const hay = `${h.hostname} ${h.ip_address} ${h.os}`.toLowerCase(); - return hay.includes(query); - }); - }, [hosts, query]); + let out = hosts; + if (query) { + out = out.filter((h) => { + const hay = `${h.hostname} ${h.ip_address} ${h.os}`.toLowerCase(); + return hay.includes(query); + }); + } + // v1.7.0 — apply the Status/OS/Compliance filter panel selections. + return applyHostFilters(out, filters); + }, [hosts, query, filters]); // Sort: down hosts first, then by compliance ascending (matches prototype). const sorted = useMemo(() => { @@ -174,6 +209,10 @@ export function HostsListPage() { }); }, [visible]); + // v1.7.0 — partition the sorted list into labelled sections when a Group + // is active (None yields a single anonymous section). Spec C-10. + const groups = useMemo(() => groupHosts(sorted, group), [sorted, group]); + // Scan-queue KPI: live queued+running counts from scan_runs. // Spec api-host-compliance AC-07 (endpoint) + frontend-hosts-list. const scanQueueQuery = useQuery({ @@ -229,7 +268,8 @@ export function HostsListPage() { const fleetAlert = fleetAlertFromHosts(hosts); - const hasFilter = !!(search.env || search.tag || query); + const filterCount = activeFilterCount(filters); + const hasFilter = !!(search.env || search.tag || query) || filterCount > 0; const updateSearch = (next: Partial) => { navigate({ @@ -383,23 +423,22 @@ export function HostsListPage() { > updateSearch({ q: v || undefined })} /> updateSearch({ group: v })} /> - + updateSearch(next)} + />
- updateSearch({ view: v })} /> + { + // Persist the choice as the per-user default AND reflect it in + // the URL for this session (shareable / refresh-stable). + setHostsViewDefault(v); + updateSearch({ view: v }); + }} + />
{hostsQuery.isError && ( @@ -416,7 +455,26 @@ export function HostsListPage() { /> )} {sorted.length > 0 && - (view === 'cards' ? : )} + (group === 'none' ? ( + view === 'cards' ? ( + + ) : ( + + ) + ) : ( +
+ {groups.map((g) => ( +
+ + {view === 'cards' ? ( + + ) : ( + + )} +
+ ))} +
+ ))}
); } @@ -681,7 +739,6 @@ function GroupSeg({ }) { const options: { value: typeof value; label: string }[] = [ { value: 'none', label: 'None' }, - { value: 'team', label: 'Team' }, { value: 'status', label: 'Status' }, { value: 'os', label: 'OS' }, ]; @@ -785,6 +842,226 @@ function ViewToggle({ ); } +// GroupHeader labels a grouped section (Status / OS) with its member +// count. Spec frontend-hosts-list C-10. +function GroupHeader({ label, count }: { label: string; count: number }) { + return ( +
+ {label} + {count} + +
+ ); +} + +const STATUS_FILTER_OPTIONS: MonitoringBand[] = [ + 'critical', + 'down', + 'degraded', + 'online', + 'maintenance', + 'unknown', +]; +const TIER_FILTER_OPTIONS: TierFilter[] = ['crit', 'warn', 'ok', 'none']; + +function FilterChip({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function FilterSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+ {title} +
+
{children}
+
+ ); +} + +// FiltersControl is the Filters button + its popover. Selections are +// multi-select within each dimension (Status / Compliance / OS) and +// persisted to the URL via onChange so a refresh restores them (C-04 / +// C-11). The OS options are derived from the loaded fleet so only present +// families show. Filtering itself is applied client-side by +// applyHostFilters in the page pipeline. +function FiltersControl({ + filters, + count, + hosts, + onChange, +}: { + filters: HostFilters; + count: number; + hosts: DevHost[]; + onChange: (next: Pick) => void; +}) { + const [open, setOpen] = useState(false); + const osOptions = useMemo( + () => [...new Set(hosts.map((h) => h.os || 'Unknown'))].sort(), + [hosts], + ); + const toggle = (dim: 'status' | 'os' | 'tier', val: string) => { + const cur = filters[dim]; + const next = cur.includes(val) ? cur.filter((v) => v !== val) : [...cur, val]; + onChange({ [dim]: next.length ? next.join(',') : undefined }); + }; + return ( +
+ + {open && ( + <> + + )} +
+ + )} + + ); +} + // ───────────────────────────────────────────────────────────────────────── // Cards view — matches prototype `.card` block // ───────────────────────────────────────────────────────────────────────── @@ -897,6 +1174,27 @@ function ScanHostButton({ hostId, variant }: { hostId: string; variant: 'card' | ); } +// ViewReportButton links the host card/row chart icon to the latest +// completed scan's detail (report) page, /scans/{latestScanId}. It +// renders nothing when the host has no completed scan (latestScanId null) +// or the viewer lacks scan:read — the destination is scan:read-gated, so +// showing a dead link would only 403. Spec frontend-hosts-list AC-24. +function ViewReportButton({ latestScanId }: { latestScanId: string | null }) { + const canRead = useAuthStore((s) => s.hasPermission('scan:read')); + if (!latestScanId || !canRead) return null; + return ( + + + + ); +} + function HostCard({ host }: { host: DevHost }) { const tier = complianceTier(host.compliance); const isDown = host.status === 'down'; @@ -1125,9 +1423,7 @@ function HostCard({ host }: { host: DevHost }) { Last scan {host.lastScan}
- +
@@ -1323,9 +1619,7 @@ function HostRow({ host }: { host: DevHost }) {
- +
@@ -1563,6 +1857,9 @@ export function apiHostToDev(h: ApiHost): DevHost { criticalFailing: cs?.critical_failing ?? 0, lastCheckMinutes, lastScan, + // v1.6.0: newest completed scan id for the "view report" link; null + // (icon hidden) when the host has no completed scan. Spec api-hosts C-13. + latestScanId: h.latest_scan_id ?? null, }; } diff --git a/frontend/src/store/usePreferencesStore.ts b/frontend/src/store/usePreferencesStore.ts index 1b22e6608..3fa4a806d 100644 --- a/frontend/src/store/usePreferencesStore.ts +++ b/frontend/src/store/usePreferencesStore.ts @@ -1,8 +1,17 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -// Preferences store — personal UI preferences persisted to localStorage. -// No backend wiring; preferences live entirely in the browser. +import api from '@/api/client'; + +// Preferences store — personal UI preferences. +// +// v2.0.0: preferences are now persisted SERVER-SIDE (per-user, follows the +// user across devices) via GET/PATCH /api/v1/users/me/preferences +// (system-user-preferences). localStorage is kept as an instant-load cache +// and offline fallback: the store hydrates from it synchronously, then +// hydrateFromServer() reconciles with the account on app start, and every +// setter writes through to the server (best-effort; a failed PATCH leaves +// the local value in place). export type Density = 'comfortable' | 'compact'; export type AccentColor = 'info' | 'ok' | 'brand2'; @@ -24,6 +33,40 @@ interface PreferencesState { setHostsViewDefault: (v: HostsViewDefault) => void; setDateFormat: (v: DateFormat) => void; setReduceMotion: (v: boolean) => void; + + // Reconcile local state with the server copy. Called once when the + // authenticated shell mounts. Server values win; keys the server has not + // set leave the local default in place. + hydrateFromServer: () => Promise; +} + +// The server contract is snake_case; the store is camelCase. These two +// helpers are the only translation points. +type ApiPrefs = { + hosts_view_default?: HostsViewDefault; + density?: Density; + accent_color?: AccentColor; + landing_page?: LandingPage; + date_format?: DateFormat; + reduce_motion?: boolean; +}; + +function serverToState(p: ApiPrefs): Partial { + const out: Partial = {}; + if (p.hosts_view_default) out.hostsViewDefault = p.hosts_view_default; + if (p.density) out.density = p.density; + if (p.accent_color) out.accentColor = p.accent_color; + if (p.landing_page) out.landingPage = p.landing_page; + if (p.date_format) out.dateFormat = p.date_format; + if (typeof p.reduce_motion === 'boolean') out.reduceMotion = p.reduce_motion; + return out; +} + +// push writes a single changed key to the server. Best-effort: a network +// error or 401 (anonymous) is swallowed — the local value still stands and +// the next successful session will reconcile. +function push(patch: ApiPrefs): void { + void api.PATCH('/api/v1/users/me/preferences', { body: patch }).catch(() => {}); } export const usePreferencesStore = create()( @@ -36,12 +79,37 @@ export const usePreferencesStore = create()( dateFormat: 'us12', reduceMotion: false, - setDensity: (density) => set({ density }), - setAccentColor: (accentColor) => set({ accentColor }), - setLandingPage: (landingPage) => set({ landingPage }), - setHostsViewDefault: (hostsViewDefault) => set({ hostsViewDefault }), - setDateFormat: (dateFormat) => set({ dateFormat }), - setReduceMotion: (reduceMotion) => set({ reduceMotion }), + setDensity: (density) => { + set({ density }); + push({ density }); + }, + setAccentColor: (accentColor) => { + set({ accentColor }); + push({ accent_color: accentColor }); + }, + setLandingPage: (landingPage) => { + set({ landingPage }); + push({ landing_page: landingPage }); + }, + setHostsViewDefault: (hostsViewDefault) => { + set({ hostsViewDefault }); + push({ hosts_view_default: hostsViewDefault }); + }, + setDateFormat: (dateFormat) => { + set({ dateFormat }); + push({ date_format: dateFormat }); + }, + setReduceMotion: (reduceMotion) => { + set({ reduceMotion }); + push({ reduce_motion: reduceMotion }); + }, + + hydrateFromServer: async () => { + const { data, error } = await api.GET('/api/v1/users/me/preferences'); + if (error || !data) return; + const patch = serverToState(data as ApiPrefs); + if (Object.keys(patch).length > 0) set(patch); + }, }), { name: 'ow-preferences' }, ), diff --git a/frontend/tests/api/host-filtering.test.ts b/frontend/tests/api/host-filtering.test.ts new file mode 100644 index 000000000..d0bb2b58c --- /dev/null +++ b/frontend/tests/api/host-filtering.test.ts @@ -0,0 +1,124 @@ +// @spec frontend-hosts-list +// +// Behavioral coverage for the pure grouping + filtering helpers that back +// the /hosts Group control and Filters popover. +// AC-23 grouping (None/Status/OS, worst-first, Unknown last) +// AC-24 filtering (AND across dims / OR within, tier buckets, parsing) + +import { describe, expect, test } from 'vitest'; + +import type { DevHost, MonitoringBand } from '@/api/host-view-model'; +import { + activeFilterCount, + applyHostFilters, + groupHosts, + hostComplianceTier, + parseHostFilters, +} from '@/api/host-filtering'; + +function host(overrides: Partial = {}): DevHost { + return { + id: Math.random().toString(36).slice(2), + hostname: 'h', + ip_address: '10.0.0.1', + os: 'Ubuntu', + status: 'online', + monitoring: 'online', + compliance: 90, + passed: 9, + failed: 1, + total: 10, + lastCheckMinutes: 0, + lastScan: 'just now', + latestScanId: null, + ...overrides, + }; +} + +describe('frontend-hosts-list/AC-23 — grouping', () => { + test('none returns a single section with the input unchanged', () => { + const hosts = [host(), host()]; + const groups = groupHosts(hosts, 'none'); + expect(groups).toHaveLength(1); + expect(groups[0]!.hosts).toEqual(hosts); + }); + + test('status groups worst-first and omits empty bands', () => { + const bands: MonitoringBand[] = ['online', 'critical', 'online', 'down', 'degraded']; + const hosts = bands.map((b) => host({ monitoring: b })); + const groups = groupHosts(hosts, 'status'); + // present bands in worst-first order: critical, down, degraded, online + expect(groups.map((g) => g.key)).toEqual(['critical', 'down', 'degraded', 'online']); + expect(groups.find((g) => g.key === 'online')!.hosts).toHaveLength(2); + // maintenance/unknown absent → no empty sections + expect(groups.some((g) => g.key === 'maintenance' || g.key === 'unknown')).toBe(false); + }); + + test('os groups alphabetically with Unknown last', () => { + const hosts = [ + host({ os: 'RHEL' }), + host({ os: 'Unknown' }), + host({ os: 'Ubuntu' }), + host({ os: 'Debian' }), + ]; + const groups = groupHosts(hosts, 'os'); + expect(groups.map((g) => g.label)).toEqual(['Debian', 'RHEL', 'Ubuntu', 'Unknown']); + }); +}); + +describe('frontend-hosts-list/AC-24 — filtering', () => { + test('compliance tier buckets; never-scanned is none, not crit', () => { + expect(hostComplianceTier(host({ compliance: null }))).toBe('none'); + expect(hostComplianceTier(host({ compliance: 0 }))).toBe('crit'); + expect(hostComplianceTier(host({ compliance: 39.9 }))).toBe('crit'); + expect(hostComplianceTier(host({ compliance: 40 }))).toBe('warn'); + expect(hostComplianceTier(host({ compliance: 79.9 }))).toBe('warn'); + expect(hostComplianceTier(host({ compliance: 80 }))).toBe('ok'); + expect(hostComplianceTier(host({ compliance: 100 }))).toBe('ok'); + }); + + test('empty filters keep everything', () => { + const hosts = [host(), host({ monitoring: 'down' })]; + expect(applyHostFilters(hosts, parseHostFilters({}))).toEqual(hosts); + }); + + test('OR within a dimension', () => { + const hosts = [ + host({ monitoring: 'online' }), + host({ monitoring: 'down' }), + host({ monitoring: 'critical' }), + ]; + const out = applyHostFilters(hosts, parseHostFilters({ status: 'down,critical' })); + expect(out.map((h) => h.monitoring).sort()).toEqual(['critical', 'down']); + }); + + test('AND across dimensions', () => { + const hosts = [ + host({ monitoring: 'down', os: 'RHEL', compliance: 10 }), // matches all + host({ monitoring: 'down', os: 'Ubuntu', compliance: 10 }), // wrong os + host({ monitoring: 'online', os: 'RHEL', compliance: 10 }), // wrong status + host({ monitoring: 'down', os: 'RHEL', compliance: 95 }), // wrong tier + ]; + const out = applyHostFilters( + hosts, + parseHostFilters({ status: 'down', os: 'RHEL', tier: 'crit' }), + ); + expect(out).toHaveLength(1); + expect(out[0]!.compliance).toBe(10); + }); + + test('null-compliance host filtered by the none tier, not crit', () => { + const hosts = [host({ compliance: null }), host({ compliance: 10 })]; + expect(applyHostFilters(hosts, parseHostFilters({ tier: 'none' }))).toHaveLength(1); + expect(applyHostFilters(hosts, parseHostFilters({ tier: 'crit' }))[0]!.compliance).toBe(10); + }); + + test('parseHostFilters splits + trims; activeFilterCount sums facets', () => { + const f = parseHostFilters({ status: 'down, critical', os: 'RHEL', tier: '' }); + expect(f.status).toEqual(['down', 'critical']); + expect(f.os).toEqual(['RHEL']); + expect(f.tier).toEqual([]); + expect(activeFilterCount(f)).toBe(3); + expect(activeFilterCount(parseHostFilters({}))).toBe(0); + }); +}); diff --git a/frontend/tests/pages/hosts-list.test.ts b/frontend/tests/pages/hosts-list.test.ts index b736d5ac0..870f19be9 100644 --- a/frontend/tests/pages/hosts-list.test.ts +++ b/frontend/tests/pages/hosts-list.test.ts @@ -64,6 +64,7 @@ function makeDevHost(overrides: Partial = {}): DevHost { total: 0, lastCheckMinutes: null, lastScan: '—', + latestScanId: null, ...overrides, }; } @@ -293,3 +294,64 @@ describe('frontend-hosts-list v1.6.0 — no demo/fixture data', () => { expect(PAGE_SRC).toMatch(/\(hostsQuery\.data \?\? \[\]\)\.map\(apiHostToDev\)/); }); }); + +describe('frontend-hosts-list v1.7.0 — view-report link', () => { + // @ac AC-22 + test('frontend-hosts-list/AC-22 — chart icon links to /scans/$scanId, gated on latestScanId + scan:read', () => { + const VM_SRC = readFileSync(resolve(process.cwd(), 'src/api/host-view-model.ts'), 'utf8'); + // DevHost carries latestScanId; apiHostToDev maps it from latest_scan_id. + expect(VM_SRC).toMatch(/latestScanId:\s*string \| null/); + expect(PAGE_SRC).toMatch(/latestScanId:\s*h\.latest_scan_id \?\? null/); + + // ViewReportButton: gated on scan:read AND a non-null latestScanId, + // returns null otherwise, and links to the scan-detail route. + expect(PAGE_SRC).toContain('function ViewReportButton'); + expect(PAGE_SRC).toMatch(/hasPermission\('scan:read'\)/); + expect(PAGE_SRC).toMatch(/if \(!latestScanId \|\| !canRead\) return null/); + expect(PAGE_SRC).toContain('to="/scans/$scanId"'); + expect(PAGE_SRC).toMatch(/params=\{\{ scanId: latestScanId \}\}/); + + // Used in BOTH the card footer and the table row (>= 2 mounts), and the + // old inert placeholder button is gone. + expect((PAGE_SRC.match(/ { + // @ac AC-23 + test('frontend-hosts-list/AC-23 — GroupSeg offers only None/Status/OS (no Team), grouping applied', () => { + // The "team" option (no backing host field) is gone from the control. + expect(PAGE_SRC).not.toMatch(/value: 'team'/); + expect(PAGE_SRC).not.toMatch(/label: 'Team'/); + // group type narrowed to none|status|os and grouped render uses groupHosts. + expect(PAGE_SRC).toMatch(/group\?:\s*'none' \| 'status' \| 'os'/); + expect(PAGE_SRC).toContain('groupHosts(sorted, group)'); + expect(PAGE_SRC).toContain(' { + // The inert placeholder Filters button is replaced by the real control. + expect(PAGE_SRC).toContain('function FiltersControl'); + expect(PAGE_SRC).toContain(' { + // No hardcoded 'cards' default — the fallback is the persisted store value. + expect(PAGE_SRC).toContain("usePreferencesStore((s) => s.hostsViewDefault)"); + expect(PAGE_SRC).toMatch( + /search\.view === 'table' \|\| search\.view === 'cards' \? search\.view : hostsViewDefault/, + ); + // Toggling persists the per-user default AND updates the URL. + expect(PAGE_SRC).toContain('setHostsViewDefault(v)'); + expect(PAGE_SRC).toContain('updateSearch({ view: v })'); + }); +}); diff --git a/frontend/tests/pages/settings.test.ts b/frontend/tests/pages/settings.test.ts index aac58d70b..4c86d2cc6 100644 --- a/frontend/tests/pages/settings.test.ts +++ b/frontend/tests/pages/settings.test.ts @@ -476,4 +476,24 @@ describe('frontend-settings — structural', () => { expect(resetCopy.length).toBeGreaterThan(0); expect(resetCopy).not.toContain('—'); }); + + // @ac AC-30 + test('frontend-settings/AC-30 — preferences store syncs server-side (localStorage cache retained)', () => { + // localStorage cache retained (instant load + offline fallback). + expect(PREFS_STORE_SRC).toMatch(/persist\(/); + expect(PREFS_STORE_SRC).toContain("name: 'ow-preferences'"); + // Write-through: setters PATCH the server via push(). + expect(PREFS_STORE_SRC).toContain("api.PATCH('/api/v1/users/me/preferences'"); + expect(PREFS_STORE_SRC).toMatch(/push\(\{ hosts_view_default: hostsViewDefault \}\)/); + // Hydration: hydrateFromServer GETs and applies present keys. + expect(PREFS_STORE_SRC).toContain('hydrateFromServer'); + expect(PREFS_STORE_SRC).toContain("api.GET('/api/v1/users/me/preferences')"); + // The authenticated shell reconciles once on mount. + const FRAME_SRC = readFileSync( + resolve(process.cwd(), 'src/components/shell/AppFrame.tsx'), + 'utf8', + ); + expect(FRAME_SRC).toContain('hydrateFromServer'); + expect(FRAME_SRC).toMatch(/useEffect\(/); + }); }); diff --git a/internal/db/migrations/0040_user_preferences.sql b/internal/db/migrations/0040_user_preferences.sql new file mode 100644 index 000000000..c8974626e --- /dev/null +++ b/internal/db/migrations/0040_user_preferences.sql @@ -0,0 +1,20 @@ +-- 0040_user_preferences.sql +-- +-- Per-user UI preferences, persisted server-side so a user's choices +-- (e.g. the /hosts grid-vs-table default) follow them across devices and +-- browsers instead of living only in localStorage. +-- +-- Stored as a single JSONB blob on the users row rather than a wide set of +-- typed columns: preferences are a small, evolving bag of personal UI knobs +-- with no relational queries against them, so a JSONB column keeps adding a +-- new preference a contract-only change (no migration per knob). The set of +-- valid keys is governed at the API layer by the typed UserPreferences +-- schema; PATCH merges via the JSONB || operator. +-- +-- Spec: system-user-preferences + api-user-preferences. + +-- +goose Up +ALTER TABLE users ADD COLUMN preferences JSONB NOT NULL DEFAULT '{}'::jsonb; + +-- +goose Down +ALTER TABLE users DROP COLUMN IF EXISTS preferences; diff --git a/internal/server/api/server.gen.go b/internal/server/api/server.gen.go index 179f9c591..4ba824211 100644 --- a/internal/server/api/server.gen.go +++ b/internal/server/api/server.gen.go @@ -885,6 +885,105 @@ func (e ScanRunQueuedStatus) Valid() bool { } } +// Defines values for UserPreferencesAccentColor. +const ( + UserPreferencesAccentColorBrand2 UserPreferencesAccentColor = "brand2" + UserPreferencesAccentColorInfo UserPreferencesAccentColor = "info" + UserPreferencesAccentColorOk UserPreferencesAccentColor = "ok" +) + +// Valid indicates whether the value is a known member of the UserPreferencesAccentColor enum. +func (e UserPreferencesAccentColor) Valid() bool { + switch e { + case UserPreferencesAccentColorBrand2: + return true + case UserPreferencesAccentColorInfo: + return true + case UserPreferencesAccentColorOk: + return true + default: + return false + } +} + +// Defines values for UserPreferencesDateFormat. +const ( + Iso24 UserPreferencesDateFormat = "iso24" + Long24 UserPreferencesDateFormat = "long24" + Us12 UserPreferencesDateFormat = "us12" +) + +// Valid indicates whether the value is a known member of the UserPreferencesDateFormat enum. +func (e UserPreferencesDateFormat) Valid() bool { + switch e { + case Iso24: + return true + case Long24: + return true + case Us12: + return true + default: + return false + } +} + +// Defines values for UserPreferencesDensity. +const ( + Comfortable UserPreferencesDensity = "comfortable" + Compact UserPreferencesDensity = "compact" +) + +// Valid indicates whether the value is a known member of the UserPreferencesDensity enum. +func (e UserPreferencesDensity) Valid() bool { + switch e { + case Comfortable: + return true + case Compact: + return true + default: + return false + } +} + +// Defines values for UserPreferencesHostsViewDefault. +const ( + Cards UserPreferencesHostsViewDefault = "cards" + Table UserPreferencesHostsViewDefault = "table" +) + +// Valid indicates whether the value is a known member of the UserPreferencesHostsViewDefault enum. +func (e UserPreferencesHostsViewDefault) Valid() bool { + switch e { + case Cards: + return true + case Table: + return true + default: + return false + } +} + +// Defines values for UserPreferencesLandingPage. +const ( + Dashboard UserPreferencesLandingPage = "dashboard" + Hosts UserPreferencesLandingPage = "hosts" + Reports UserPreferencesLandingPage = "reports" +) + +// Valid indicates whether the value is a known member of the UserPreferencesLandingPage enum. +func (e UserPreferencesLandingPage) Valid() bool { + switch e { + case Dashboard: + return true + case Hosts: + return true + case Reports: + return true + default: + return false + } +} + // Defines values for GetActivityParamsSource. const ( GetActivityParamsSourceAlert GetActivityParamsSource = "alert" @@ -1022,25 +1121,25 @@ func (e GetComplianceExceptionsParamsStatus) Valid() bool { // Defines values for GetIntelligenceEventsParamsSeverity. const ( - GetIntelligenceEventsParamsSeverityCritical GetIntelligenceEventsParamsSeverity = "critical" - GetIntelligenceEventsParamsSeverityHigh GetIntelligenceEventsParamsSeverity = "high" - GetIntelligenceEventsParamsSeverityInfo GetIntelligenceEventsParamsSeverity = "info" - GetIntelligenceEventsParamsSeverityLow GetIntelligenceEventsParamsSeverity = "low" - GetIntelligenceEventsParamsSeverityMedium GetIntelligenceEventsParamsSeverity = "medium" + Critical GetIntelligenceEventsParamsSeverity = "critical" + High GetIntelligenceEventsParamsSeverity = "high" + Info GetIntelligenceEventsParamsSeverity = "info" + Low GetIntelligenceEventsParamsSeverity = "low" + Medium GetIntelligenceEventsParamsSeverity = "medium" ) // Valid indicates whether the value is a known member of the GetIntelligenceEventsParamsSeverity enum. func (e GetIntelligenceEventsParamsSeverity) Valid() bool { switch e { - case GetIntelligenceEventsParamsSeverityCritical: + case Critical: return true - case GetIntelligenceEventsParamsSeverityHigh: + case High: return true - case GetIntelligenceEventsParamsSeverityInfo: + case Info: return true - case GetIntelligenceEventsParamsSeverityLow: + case Low: return true - case GetIntelligenceEventsParamsSeverityMedium: + case Medium: return true default: return false @@ -2013,6 +2112,7 @@ type HostListItem struct { Id openapi_types.UUID `json:"id"` IpAddress string `json:"ip_address"` LastScanAt *time.Time `json:"last_scan_at,omitempty"` + LatestScanId *openapi_types.UUID `json:"latest_scan_id,omitempty"` // Liveness Null when no liveness probe has ever run against this host. Liveness *HostLiveness `json:"liveness,omitempty"` @@ -2809,6 +2909,31 @@ type UserPasswordResetRequest struct { NewPassword string `json:"new_password"` } +// UserPreferences defines model for UserPreferences. +type UserPreferences struct { + AccentColor *UserPreferencesAccentColor `json:"accent_color,omitempty"` + DateFormat *UserPreferencesDateFormat `json:"date_format,omitempty"` + Density *UserPreferencesDensity `json:"density,omitempty"` + HostsViewDefault *UserPreferencesHostsViewDefault `json:"hosts_view_default,omitempty"` + LandingPage *UserPreferencesLandingPage `json:"landing_page,omitempty"` + ReduceMotion *bool `json:"reduce_motion,omitempty"` +} + +// UserPreferencesAccentColor defines model for UserPreferences.AccentColor. +type UserPreferencesAccentColor string + +// UserPreferencesDateFormat defines model for UserPreferences.DateFormat. +type UserPreferencesDateFormat string + +// UserPreferencesDensity defines model for UserPreferences.Density. +type UserPreferencesDensity string + +// UserPreferencesHostsViewDefault defines model for UserPreferences.HostsViewDefault. +type UserPreferencesHostsViewDefault string + +// UserPreferencesLandingPage defines model for UserPreferences.LandingPage. +type UserPreferencesLandingPage string + // UserResponse defines model for UserResponse. type UserResponse struct { CreatedAt *time.Time `json:"created_at,omitempty"` @@ -3229,6 +3354,9 @@ type PostAPITokenJSONRequestBody = ApiTokenCreateRequest // PostUsersJSONRequestBody defines body for PostUsers for application/json ContentType. type PostUsersJSONRequestBody = UserCreateRequest +// PatchUsersMePreferencesJSONRequestBody defines body for PatchUsersMePreferences for application/json ContentType. +type PatchUsersMePreferencesJSONRequestBody = UserPreferences + // PostUserRolesAssignJSONRequestBody defines body for PostUserRolesAssign for application/json ContentType. type PostUserRolesAssignJSONRequestBody = UserRoleAssignRequest @@ -3630,6 +3758,12 @@ type ServerInterface interface { // Create a user // (POST /api/v1/users) PostUsers(w http.ResponseWriter, r *http.Request) + // Return the calling user's UI preferences + // (GET /api/v1/users/me/preferences) + GetUsersMePreferences(w http.ResponseWriter, r *http.Request) + // Merge a partial update into the calling user's UI preferences + // (PATCH /api/v1/users/me/preferences) + PatchUsersMePreferences(w http.ResponseWriter, r *http.Request) // Soft-delete a user // (DELETE /api/v1/users/{id}) DeleteUserByID(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) @@ -4440,6 +4574,18 @@ func (_ Unimplemented) PostUsers(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Return the calling user's UI preferences +// (GET /api/v1/users/me/preferences) +func (_ Unimplemented) GetUsersMePreferences(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Merge a partial update into the calling user's UI preferences +// (PATCH /api/v1/users/me/preferences) +func (_ Unimplemented) PatchUsersMePreferences(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Soft-delete a user // (DELETE /api/v1/users/{id}) func (_ Unimplemented) DeleteUserByID(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { @@ -8155,6 +8301,34 @@ func (siw *ServerInterfaceWrapper) PostUsers(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } +// GetUsersMePreferences operation middleware +func (siw *ServerInterfaceWrapper) GetUsersMePreferences(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetUsersMePreferences(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PatchUsersMePreferences operation middleware +func (siw *ServerInterfaceWrapper) PatchUsersMePreferences(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PatchUsersMePreferences(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // DeleteUserByID operation middleware func (siw *ServerInterfaceWrapper) DeleteUserByID(w http.ResponseWriter, r *http.Request) { @@ -8854,6 +9028,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/users", wrapper.PostUsers) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/users/me/preferences", wrapper.GetUsersMePreferences) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/api/v1/users/me/preferences", wrapper.PatchUsersMePreferences) + }) r.Group(func(r chi.Router) { r.Delete(options.BaseURL+"/api/v1/users/{id}", wrapper.DeleteUserByID) }) @@ -8996,363 +9176,371 @@ var swaggerSpec = []string{ "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/server/api_hosts_enrichment_test.go b/internal/server/api_hosts_enrichment_test.go index bd13d162d..95ae77488 100644 --- a/internal/server/api_hosts_enrichment_test.go +++ b/internal/server/api_hosts_enrichment_test.go @@ -9,6 +9,7 @@ // AC-18 TestHosts_GetByID_Enrichment_FrameworkFilterEmpty // AC-19 TestHosts_GetHosts_ListLivenessJoined // AC-23 TestHosts_GetHosts_ListComplianceSummaryJoined +// AC-24 TestHosts_GetHosts_ListLatestScanIDJoined package server @@ -419,3 +420,81 @@ func TestHosts_GetHosts_ListComplianceSummaryJoined(t *testing.T) { } }) } + +// seedScanRun inserts a scan_runs row with the given status + queued_at +// and returns its id. +func seedScanRun(t *testing.T, pool *pgxpool.Pool, hostID uuid.UUID, status string, queuedAt time.Time) uuid.UUID { + t.Helper() + id, _ := uuid.NewV7() + _, err := pool.Exec(context.Background(), ` + INSERT INTO scan_runs (id, host_id, trigger_source, status, queued_at) + VALUES ($1, $2, 'scheduled', $3, $4)`, + id, hostID, status, queuedAt) + if err != nil { + t.Fatalf("seed scan_runs: %v", err) + } + return id +} + +// @ac AC-24 +// AC-24 (v1.6.0): GET /hosts items carry a nullable latest_scan_id = the +// newest COMPLETED scan_run id. A host with an older+newer completed run +// (plus an even-newer queued run) resolves to the newer COMPLETED id (not +// the queued one); a host with only a queued/running run, and a host with +// no runs at all, both resolve to null. +func TestHosts_GetHosts_ListLatestScanIDJoined(t *testing.T) { + t.Run("api-hosts/AC-24", func(t *testing.T) { + url, pool := freshAPIServer(t) + withCompleted := createHostAPI(t, url, "scanned-host", "production") + withCompletedID, _ := uuid.Parse(withCompleted["id"].(string)) + queuedOnly := createHostAPI(t, url, "queued-host", "production") + queuedOnlyID := queuedOnly["id"].(string) + queuedOnlyUUID, _ := uuid.Parse(queuedOnlyID) + noScans := createHostAPI(t, url, "noscan-host", "production") + noScansID := noScans["id"].(string) + + base := time.Now().UTC().Truncate(time.Second) + // host A: older completed, newer completed (the answer), even-newer queued. + seedScanRun(t, pool, withCompletedID, "completed", base.Add(-2*time.Hour)) + wantID := seedScanRun(t, pool, withCompletedID, "completed", base.Add(-1*time.Hour)) + seedScanRun(t, pool, withCompletedID, "queued", base) // newest, but not a viewable report + // host B: only a queued/running run. + seedScanRun(t, pool, queuedOnlyUUID, "running", base) + + req := asRole(t, "GET", url+"/api/v1/hosts", auth.RoleAdmin, nil) + resp := doReq(t, req) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var body struct { + Hosts []struct { + ID string `json:"id"` + LatestScanID *string `json:"latest_scan_id"` + } `json:"hosts"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + seen := map[string]*string{} + for _, h := range body.Hosts { + seen[h.ID] = h.LatestScanID + } + + if got, ok := seen[withCompletedID.String()]; !ok || got == nil { + t.Fatalf("scanned host latest_scan_id missing/null; want %s", wantID) + } else if *got != wantID.String() { + t.Errorf("latest_scan_id = %s, want %s (newest COMPLETED, not the queued run)", *got, wantID) + } + if got, ok := seen[queuedOnlyID]; !ok { + t.Error("queued-only host absent from /hosts response") + } else if got != nil { + t.Errorf("queued-only host latest_scan_id = %v, want null", *got) + } + if got, ok := seen[noScansID]; !ok { + t.Error("no-scan host absent from /hosts response") + } else if got != nil { + t.Errorf("no-scan host latest_scan_id = %v, want null", *got) + } + }) +} diff --git a/internal/server/api_userpref_test.go b/internal/server/api_userpref_test.go new file mode 100644 index 000000000..37a01d6e7 --- /dev/null +++ b/internal/server/api_userpref_test.go @@ -0,0 +1,143 @@ +// @spec system-user-preferences +// +// AC traceability (this file): +// AC-01 TestUserPrefs_Get_EmptyWhenUnset +// AC-02 TestUserPrefs_Patch_ShallowMerge +// AC-03 TestUserPrefs_Patch_InvalidEnumRejected +// AC-04 TestUserPrefs_AnonymousRejected + +package server + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/Hanalyx/openwatch/internal/auth" +) + +type prefsBody struct { + HostsViewDefault *string `json:"hosts_view_default"` + Density *string `json:"density"` + AccentColor *string `json:"accent_color"` +} + +func getPrefs(t *testing.T, url string, role auth.RoleID) (int, prefsBody) { + t.Helper() + req := asRole(t, "GET", url+"/api/v1/users/me/preferences", role, nil) + resp := doReq(t, req) + defer resp.Body.Close() + var p prefsBody + if resp.StatusCode == http.StatusOK { + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + t.Fatalf("decode prefs: %v", err) + } + } + return resp.StatusCode, p +} + +func patchPrefs(t *testing.T, url string, role auth.RoleID, body any) (int, prefsBody) { + t.Helper() + req := asRole(t, "PATCH", url+"/api/v1/users/me/preferences", role, body) + resp := doReq(t, req) + defer resp.Body.Close() + var p prefsBody + if resp.StatusCode == http.StatusOK { + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + t.Fatalf("decode prefs: %v", err) + } + } + return resp.StatusCode, p +} + +// @ac AC-01 +func TestUserPrefs_Get_EmptyWhenUnset(t *testing.T) { + t.Run("system-user-preferences/AC-01", func(t *testing.T) { + url, _ := freshAPIServer(t) + status, p := getPrefs(t, url, auth.RoleAdmin) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + if p.HostsViewDefault != nil || p.Density != nil || p.AccentColor != nil { + t.Errorf("unset prefs returned %+v, want all nil (empty object)", p) + } + }) +} + +// @ac AC-02 +func TestUserPrefs_Patch_ShallowMerge(t *testing.T) { + t.Run("system-user-preferences/AC-02", func(t *testing.T) { + url, _ := freshAPIServer(t) + + // First PATCH sets hosts_view_default. + status, p := patchPrefs(t, url, auth.RoleAdmin, map[string]any{"hosts_view_default": "table"}) + if status != http.StatusOK { + t.Fatalf("first patch status = %d, want 200", status) + } + if p.HostsViewDefault == nil || *p.HostsViewDefault != "table" { + t.Fatalf("hosts_view_default = %v, want table", p.HostsViewDefault) + } + + // Second PATCH sets a DIFFERENT key; the first MUST be retained (merge). + status, p = patchPrefs(t, url, auth.RoleAdmin, map[string]any{"density": "compact"}) + if status != http.StatusOK { + t.Fatalf("second patch status = %d, want 200", status) + } + if p.Density == nil || *p.Density != "compact" { + t.Errorf("density = %v, want compact", p.Density) + } + if p.HostsViewDefault == nil || *p.HostsViewDefault != "table" { + t.Errorf("hosts_view_default = %v after merge, want table retained (not overwritten)", + p.HostsViewDefault) + } + + // And a fresh GET reflects the merged state. + _, got := getPrefs(t, url, auth.RoleAdmin) + if got.HostsViewDefault == nil || *got.HostsViewDefault != "table" || + got.Density == nil || *got.Density != "compact" { + t.Errorf("GET after merge = %+v, want table + compact", got) + } + }) +} + +// @ac AC-03 +func TestUserPrefs_Patch_InvalidEnumRejected(t *testing.T) { + t.Run("system-user-preferences/AC-03", func(t *testing.T) { + url, _ := freshAPIServer(t) + // Seed a valid value first. + patchPrefs(t, url, auth.RoleAdmin, map[string]any{"hosts_view_default": "cards"}) + + // An out-of-range enum is rejected 400 and must NOT persist. + req := asRole(t, "PATCH", url+"/api/v1/users/me/preferences", auth.RoleAdmin, + map[string]any{"hosts_view_default": "weird"}) + resp := doReq(t, req) + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for invalid enum", resp.StatusCode) + } + + _, got := getPrefs(t, url, auth.RoleAdmin) + if got.HostsViewDefault == nil || *got.HostsViewDefault != "cards" { + t.Errorf("after rejected patch hosts_view_default = %v, want cards (unchanged)", + got.HostsViewDefault) + } + }) +} + +// @ac AC-04 +func TestUserPrefs_AnonymousRejected(t *testing.T) { + t.Run("system-user-preferences/AC-04", func(t *testing.T) { + url, _ := freshAPIServer(t) + // role "" → no session cookie → anonymous. + if status, _ := getPrefs(t, url, ""); status != http.StatusUnauthorized { + t.Errorf("anonymous GET status = %d, want 401", status) + } + req := asRole(t, "PATCH", url+"/api/v1/users/me/preferences", "", + map[string]any{"hosts_view_default": "table"}) + resp := doReq(t, req) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("anonymous PATCH status = %d, want 401", resp.StatusCode) + } + }) +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 9b8803ebf..75e23ade7 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -35,6 +35,7 @@ import ( "github.com/Hanalyx/openwatch/internal/server/api" "github.com/Hanalyx/openwatch/internal/sso" "github.com/Hanalyx/openwatch/internal/systemconfig" + "github.com/Hanalyx/openwatch/internal/userpref" "github.com/Hanalyx/openwatch/internal/users" "github.com/Hanalyx/openwatch/internal/version" "github.com/google/uuid" @@ -140,6 +141,11 @@ type handlers struct { // SSO (OIDC) providers + sign-in flow. Always wired in server.New (it // wraps the pool + the mandated outbound client). Spec api-sso. ssoSvc *sso.Service + + // Per-user UI preferences (users.preferences JSONB). Always wired in + // newHandlers (it only wraps the pool), so /users/me/preferences is + // never 503. Spec system-user-preferences + api-user-preferences. + userPrefSvc *userpref.Service } // newHandlers constructs the ServerInterface implementation. The user @@ -152,6 +158,7 @@ func newHandlers(pool *pgxpool.Pool) *handlers { credentials: credential.NewService(pool), hosts: host.NewService(pool), fleet: fleetrollup.NewService(pool), + userPrefSvc: userpref.NewService(pool), } } diff --git a/internal/server/hosts_enrichment.go b/internal/server/hosts_enrichment.go index e7f7900f3..9282006df 100644 --- a/internal/server/hosts_enrichment.go +++ b/internal/server/hosts_enrichment.go @@ -173,6 +173,41 @@ func loadHostLastScanByIDs(ctx context.Context, pool *pgxpool.Pool, ids []uuid.U return out, rows.Err() } +// loadHostLatestScanIDByIDs returns the id of the newest COMPLETED +// scan_runs row per host, keyed by host id. Hosts with no completed +// scan_run don't appear in the map — the list handler renders that as +// latest_scan_id: null, so the host card hides its "view report" link. +// +// A queued/running-only host is intentionally excluded: the link targets +// GET /scans/{id}, the scan-detail (report) page, which only has results +// once the run completes. ONE query for the whole page (DISTINCT ON over +// the scan_runs_host_recent index) — no per-host N+1. Spec api-hosts +// v1.6.0 C-13. +func loadHostLatestScanIDByIDs(ctx context.Context, pool *pgxpool.Pool, ids []uuid.UUID) (map[uuid.UUID]uuid.UUID, error) { + out := map[uuid.UUID]uuid.UUID{} + if len(ids) == 0 { + return out, nil + } + const q = ` + SELECT DISTINCT ON (host_id) host_id, id + FROM scan_runs + WHERE host_id = ANY($1) AND status = 'completed' + ORDER BY host_id, queued_at DESC` + rows, err := pool.Query(ctx, q, ids) + if err != nil { + return nil, fmt.Errorf("loadHostLatestScanIDByIDs: %w", err) + } + defer rows.Close() + for rows.Next() { + var hid, sid uuid.UUID + if err := rows.Scan(&hid, &sid); err != nil { + return nil, fmt.Errorf("loadHostLatestScanIDByIDs scan: %w", err) + } + out[hid] = sid + } + return out, rows.Err() +} + // loadHostListComplianceByIDs returns per-host compliance roll-ups // keyed by host id — ONE grouped query against host_rule_state for the // whole page, no per-host N+1. Hosts with zero rule_state rows don't diff --git a/internal/server/hosts_handlers.go b/internal/server/hosts_handlers.go index 5c62d4548..7375af8c0 100644 --- a/internal/server/hosts_handlers.go +++ b/internal/server/hosts_handlers.go @@ -67,11 +67,17 @@ func (h *handlers) GetHosts(w http.ResponseWriter, r *http.Request, params api.G "compliance summary join failed", true) return } + latestScanByID, err := loadHostLatestScanIDByIDs(r.Context(), h.pool, ids) + if err != nil { + writeError(w, http.StatusInternalServerError, "server.error", "server", + "latest_scan_id join failed", true) + return + } out := make([]api.HostListItem, len(list)) for i, item := range list { out[i] = hostListItem(item, liveByID[item.ID], lastScanByID[item.ID], - complianceByID[item.ID]) + complianceByID[item.ID], latestScanByID[item.ID]) } writeJSON(w, http.StatusOK, api.HostListResponse{Hosts: out}) } @@ -336,7 +342,7 @@ func hostResponse(h host.Host) api.HostResponse { // and an optional compliance roll-up (api-hosts v1.5.0 C-12). All // three may be nil — never probed and never scanned, respectively. func hostListItem(h host.Host, liveness *api.HostLiveness, lastScan time.Time, - compliance *api.HostListComplianceSummary) api.HostListItem { + compliance *api.HostListComplianceSummary, latestScanID uuid.UUID) api.HostListItem { desc := h.Description displayName := h.DisplayName env := h.Environment @@ -380,5 +386,11 @@ func hostListItem(h host.Host, liveness *api.HostLiveness, lastScan time.Time, if !lastScan.IsZero() { item.LastScanAt = &lastScan } + // v1.6.0 — nil (null on the wire) when the host has no completed + // scan_run. Spec api-hosts C-13. + if latestScanID != uuid.Nil { + u := openapitypes.UUID(latestScanID) + item.LatestScanId = &u + } return item } diff --git a/internal/server/openapi_docs.go b/internal/server/openapi_docs.go index dfde7a9bd..24e083d2b 100644 --- a/internal/server/openapi_docs.go +++ b/internal/server/openapi_docs.go @@ -18,6 +18,13 @@ import ( // build time so the docs travel with the artifact and require no // runtime filesystem access — air-gap clean. // +// openapi_embed.yaml is a gitignored build-time copy of api/openapi.yaml +// (go:embed cannot reference paths outside the package dir). It is kept in +// sync by `make generate-api` / `make build`; the directive below lets +// `go generate ./...` refresh it too, and TestOpenAPIDocs_EmbeddedMatchesSource +// fails the build if it ever drifts. +// +//go:generate cp ../../api/openapi.yaml openapi_embed.yaml //go:embed openapi_embed.yaml var openAPISpec []byte diff --git a/internal/server/userpref_handlers.go b/internal/server/userpref_handlers.go new file mode 100644 index 000000000..197867fcc --- /dev/null +++ b/internal/server/userpref_handlers.go @@ -0,0 +1,149 @@ +// Per-user UI preferences HTTP surface. Self-scoped: every authenticated +// identity reads and updates ONLY its own preferences (the user id comes +// from the session/bearer identity, never from a path param), so there is +// no RBAC permission to check beyond "is authenticated". Spec: +// system-user-preferences. + +package server + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/google/uuid" + + "github.com/Hanalyx/openwatch/internal/auth" + "github.com/Hanalyx/openwatch/internal/server/api" + "github.com/Hanalyx/openwatch/internal/userpref" +) + +// meUserID resolves the calling identity to an active user UUID, writing a +// 401 and returning ok=false when the caller is anonymous or carries a +// malformed id. Mirrors GetAuthMe's guard. +func (h *handlers) meUserID(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) { + id := auth.FromContext(r.Context()) + if id.IsAnonymous { + writeError(w, http.StatusUnauthorized, "auth.required", "client", + "authentication required", false) + return uuid.Nil, false + } + userID, err := uuid.Parse(id.ID) + if err != nil { + writeError(w, http.StatusUnauthorized, "auth.required", "client", + "identity id is not a UUID", false) + return uuid.Nil, false + } + return userID, true +} + +// GetUsersMePreferences returns the caller's stored UI preferences. +// Spec system-user-preferences AC-01. +func (h *handlers) GetUsersMePreferences(w http.ResponseWriter, r *http.Request) { + userID, ok := h.meUserID(w, r) + if !ok { + return + } + if h.userPrefSvc == nil { + writeError(w, http.StatusServiceUnavailable, "server.unavailable", "server", + "preferences service not configured", true) + return + } + raw, err := h.userPrefSvc.Get(r.Context(), userID) + if err != nil { + if errors.Is(err, userpref.ErrUserNotFound) { + writeError(w, http.StatusUnauthorized, "auth.required", "client", + "identity user not found", false) + return + } + writeError(w, http.StatusInternalServerError, "server.error", "server", + "read preferences failed", true) + return + } + // Project the stored blob through the typed contract so only known keys + // surface (additionalProperties:false), dropping any legacy junk. + var prefs api.UserPreferences + if err := json.Unmarshal(raw, &prefs); err != nil { + writeError(w, http.StatusInternalServerError, "server.error", "server", + "stored preferences are malformed", true) + return + } + writeJSON(w, http.StatusOK, prefs) +} + +// PatchUsersMePreferences shallow-merges the provided keys into the +// caller's preferences and returns the merged result. Only known, +// well-typed keys are merged — unknown keys are dropped at decode, invalid +// enum values are rejected 400. Spec system-user-preferences AC-02, AC-03, AC-04. +func (h *handlers) PatchUsersMePreferences(w http.ResponseWriter, r *http.Request) { + userID, ok := h.meUserID(w, r) + if !ok { + return + } + if h.userPrefSvc == nil { + writeError(w, http.StatusServiceUnavailable, "server.unavailable", "server", + "preferences service not configured", true) + return + } + var body api.UserPreferences + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "validation.invalid_body", "client", + "preferences body is not valid JSON", false) + return + } + if msg, valid := validateUserPrefs(body); !valid { + writeError(w, http.StatusBadRequest, "validation.invalid_value", "client", msg, false) + return + } + // Re-marshal the typed struct: every field is a pointer with omitempty, + // so the patch carries exactly the keys the caller supplied — never a + // full overwrite of unset keys. + patch, err := json.Marshal(body) + if err != nil { + writeError(w, http.StatusInternalServerError, "server.error", "server", + "encode patch failed", true) + return + } + merged, err := h.userPrefSvc.Merge(r.Context(), userID, patch) + if err != nil { + if errors.Is(err, userpref.ErrUserNotFound) { + writeError(w, http.StatusUnauthorized, "auth.required", "client", + "identity user not found", false) + return + } + writeError(w, http.StatusInternalServerError, "server.error", "server", + "merge preferences failed", true) + return + } + var prefs api.UserPreferences + if err := json.Unmarshal(merged, &prefs); err != nil { + writeError(w, http.StatusInternalServerError, "server.error", "server", + "stored preferences are malformed", true) + return + } + writeJSON(w, http.StatusOK, prefs) +} + +// validateUserPrefs checks each present field against its allowed enum +// set. The generated typed string fields do not self-validate at decode, +// so an out-of-range value (e.g. hosts_view_default:"weird") would +// otherwise persist. Returns (message, false) on the first invalid field. +func validateUserPrefs(p api.UserPreferences) (string, bool) { + if v := p.HostsViewDefault; v != nil && *v != api.Table && *v != api.Cards { + return "hosts_view_default must be 'table' or 'cards'", false + } + if v := p.Density; v != nil && *v != api.Comfortable && *v != api.Compact { + return "density must be 'comfortable' or 'compact'", false + } + if v := p.AccentColor; v != nil && *v != api.UserPreferencesAccentColorInfo && + *v != api.UserPreferencesAccentColorOk && *v != api.UserPreferencesAccentColorBrand2 { + return "accent_color must be 'info', 'ok', or 'brand2'", false + } + if v := p.LandingPage; v != nil && *v != api.Hosts && *v != api.Dashboard && *v != api.Reports { + return "landing_page must be 'hosts', 'dashboard', or 'reports'", false + } + if v := p.DateFormat; v != nil && *v != api.Us12 && *v != api.Iso24 && *v != api.Long24 { + return "date_format must be 'us12', 'iso24', or 'long24'", false + } + return "", true +} diff --git a/internal/userpref/userpref.go b/internal/userpref/userpref.go new file mode 100644 index 000000000..9aba92807 --- /dev/null +++ b/internal/userpref/userpref.go @@ -0,0 +1,88 @@ +// Package userpref owns per-user UI preferences, stored as the JSONB +// users.preferences column (migration 0040). It is intentionally tiny and +// schema-agnostic at this layer: Get returns the raw blob, Merge applies a +// shallow JSON merge. The SET of valid preference keys is governed one +// layer up by the typed api.UserPreferences contract — this package only +// guarantees the merge is atomic and scoped to a single, active user. +// +// Spec: system-user-preferences. +package userpref + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrUserNotFound is returned when the target user id is unknown, soft- +// deleted, or disabled — the same "no active user" condition the users +// service guards, kept as a distinct error so the handler maps it to 401. +var ErrUserNotFound = errors.New("userpref: user not found") + +// Service reads and merges the users.preferences JSONB column. +type Service struct { + pool *pgxpool.Pool +} + +// NewService binds a Service to a DB pool. +func NewService(pool *pgxpool.Pool) *Service { + return &Service{pool: pool} +} + +// Get returns the user's stored preferences blob, or `{}` for a user who +// has never set one (the column defaults to '{}'). ErrUserNotFound for an +// unknown / soft-deleted user. +func (s *Service) Get(ctx context.Context, userID uuid.UUID) (json.RawMessage, error) { + const q = `SELECT preferences FROM users WHERE id = $1 AND deleted_at IS NULL` + var raw []byte + err := s.pool.QueryRow(ctx, q, userID).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("userpref: get: %w", err) + } + if len(raw) == 0 { + return json.RawMessage("{}"), nil + } + return json.RawMessage(raw), nil +} + +// Merge applies a shallow merge of patch onto the user's stored +// preferences (Postgres `||`: top-level keys in patch overwrite, others +// are retained) and returns the merged result. A patch of `{}` is a no-op +// read. ErrUserNotFound for an unknown / soft-deleted user. +// +// patch MUST be a JSON object; the handler validates the key set against +// the typed contract before calling Merge, so this layer trusts the shape +// but still defends against a non-object blob. +func (s *Service) Merge(ctx context.Context, userID uuid.UUID, patch json.RawMessage) (json.RawMessage, error) { + if len(patch) == 0 { + patch = json.RawMessage("{}") + } + // Defend against a non-object patch reaching the JSONB || operator + // (which would error or, worse, replace the object with a scalar). + var probe map[string]json.RawMessage + if err := json.Unmarshal(patch, &probe); err != nil { + return nil, fmt.Errorf("userpref: patch is not a JSON object: %w", err) + } + const q = ` + UPDATE users + SET preferences = preferences || $2::jsonb, updated_at = now() + WHERE id = $1 AND deleted_at IS NULL + RETURNING preferences` + var raw []byte + err := s.pool.QueryRow(ctx, q, userID, []byte(patch)).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("userpref: merge: %w", err) + } + return json.RawMessage(raw), nil +} diff --git a/internal/userpref/userpref_test.go b/internal/userpref/userpref_test.go new file mode 100644 index 000000000..624d00261 --- /dev/null +++ b/internal/userpref/userpref_test.go @@ -0,0 +1,85 @@ +// @spec system-user-preferences +// +// Service-level storage + merge semantics. DSN-gated: skipped without +// OPENWATCH_TEST_DSN. + +package userpref + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Hanalyx/openwatch/internal/db/dbtest" +) + +func freshService(t *testing.T) (*Service, *pgxpool.Pool) { + t.Helper() + pool := dbtest.Pool(t) + _, _ = pool.Exec(context.Background(), "TRUNCATE TABLE users CASCADE") + return NewService(pool), pool +} + +func seedUser(t *testing.T, pool *pgxpool.Pool) uuid.UUID { + t.Helper() + id, _ := uuid.NewV7() + _, err := pool.Exec(context.Background(), + `INSERT INTO users (id, username, email, password_hash) + VALUES ($1, $2, $3, $4)`, + id, "pref-"+id.String(), id.String()+"@example.com", + "$argon2id$v=19$m=65536,t=3,p=1$00$00") + if err != nil { + t.Fatalf("seed user: %v", err) + } + return id +} + +// @ac AC-05 +func TestUserPref_Service_GetMerge(t *testing.T) { + t.Run("system-user-preferences/AC-05", func(t *testing.T) { + svc, pool := freshService(t) + ctx := context.Background() + uid := seedUser(t, pool) + + // Default column → "{}". + raw, err := svc.Get(ctx, uid) + if err != nil { + t.Fatalf("Get default: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil || len(m) != 0 { + t.Fatalf("default prefs = %s, want empty object", raw) + } + + // Merge two keys across two calls — shallow merge retains the first. + if _, err := svc.Merge(ctx, uid, json.RawMessage(`{"hosts_view_default":"table"}`)); err != nil { + t.Fatalf("Merge 1: %v", err) + } + merged, err := svc.Merge(ctx, uid, json.RawMessage(`{"density":"compact"}`)) + if err != nil { + t.Fatalf("Merge 2: %v", err) + } + _ = json.Unmarshal(merged, &m) + if m["hosts_view_default"] != "table" || m["density"] != "compact" { + t.Errorf("merged = %s, want both keys retained", merged) + } + + // Unknown user → ErrUserNotFound on both Get and Merge. + ghost, _ := uuid.NewV7() + if _, err := svc.Get(ctx, ghost); !errors.Is(err, ErrUserNotFound) { + t.Errorf("Get(ghost) err = %v, want ErrUserNotFound", err) + } + if _, err := svc.Merge(ctx, ghost, json.RawMessage(`{"density":"compact"}`)); !errors.Is(err, ErrUserNotFound) { + t.Errorf("Merge(ghost) err = %v, want ErrUserNotFound", err) + } + + // A non-object patch is rejected before touching the column. + if _, err := svc.Merge(ctx, uid, json.RawMessage(`"scalar"`)); err == nil { + t.Error("Merge(scalar) err = nil, want rejection") + } + }) +} diff --git a/specs/api/hosts.spec.yaml b/specs/api/hosts.spec.yaml index b8845e227..b65e7e2d5 100644 --- a/specs/api/hosts.spec.yaml +++ b/specs/api/hosts.spec.yaml @@ -1,7 +1,7 @@ spec: id: api-hosts title: Host inventory CRUD HTTP surface + Slice B enrichments - version: "1.5.0" + version: "1.6.0" status: approved tier: 2 @@ -90,6 +90,10 @@ spec: description: 'v1.5.0 — GET /hosts response items MUST include a nullable compliance_summary sub-object {passing, failing, skipped, error, total, critical_failing} derived from host_rule_state. critical_failing counts rows with current_status=fail AND critical severity (case-insensitive). A host with zero host_rule_state rows MUST receive null (never zeros, never an error). Constraint note: the counts MUST be loaded with ONE grouped query against host_rule_state keyed by host_id for the whole page — no per-host N+1. Existing list semantics (filters, soft-delete exclusion) are untouched' type: technical enforcement: error + - id: C-13 + description: 'v1.6.0 — GET /hosts response items MUST include a nullable latest_scan_id (uuid) holding the id of the newest COMPLETED scan_runs row for that host (status=completed, newest by queued_at). A host with no completed scan_run MUST receive null (never an error). It MUST be loaded with ONE query keyed by host_id for the whole page (DISTINCT ON / grouped — no per-host N+1). This id is the target of the host card "view report" link to GET /scans/{id}; a queued/running-only host yields null so the UI hides the affordance. Existing list semantics (filters, soft-delete exclusion) are untouched' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -180,3 +184,7 @@ spec: description: 'v1.5.0 — GET /hosts where one host has mixed host_rule_state rows (including a critical-severity failure) and another has none returns the first item with compliance_summary populated (correct passing/failing/skipped/error/total and critical_failing counting only fail+critical rows) and the second with compliance_summary null — never zeros, never an error. The counts come from a single grouped host_rule_state query for the whole page (no N+1)' priority: critical references_constraints: [C-12] + - id: AC-24 + description: 'v1.6.0 — GET /hosts over a fleet where one host has a completed scan_run (plus an older completed one and a newer queued one), another host has only a queued/running scan_run, and a third has no scan_runs returns: the first item latest_scan_id = the newest COMPLETED run id (not the queued one, not the older completed one); the second item latest_scan_id null (queued/running is not a viewable report); the third item latest_scan_id null. The ids come from a single per-page query (no N+1).' + priority: critical + references_constraints: [C-13] diff --git a/specs/frontend/hosts-list.spec.yaml b/specs/frontend/hosts-list.spec.yaml index b098dabff..3c2b72a35 100644 --- a/specs/frontend/hosts-list.spec.yaml +++ b/specs/frontend/hosts-list.spec.yaml @@ -1,7 +1,7 @@ spec: id: frontend-hosts-list title: Hosts list — fleet dashboard with search, filter, card/table views - version: "1.6.0" + version: "1.7.0" status: approved tier: 2 @@ -80,6 +80,22 @@ spec: description: Every interactive element (sort headers, filter dropdowns, pagination buttons, row link, Add host button) MUST be reachable by keyboard alone and carry an explicit ARIA label. The page MUST pass axe-core wcag2a + wcag2aa with zero violations type: technical enforcement: error + - id: C-09 + description: v1.7.0 — each host card and table row MUST render a "view report" chart-icon control that links to the latest completed scan's detail page, /scans/{latestScanId}, where latestScanId is the list item's latest_scan_id (api-hosts C-13). The control MUST be hidden (render nothing) when latest_scan_id is null (no completed scan) OR the identity lacks scan:read — the destination is scan:read-gated, so a visible dead link would only 403. The scan:read check uses useAuthStore, not a role-string comparison + type: security + enforcement: error + - id: C-10 + description: 'v1.7.0 — the Group control MUST offer exactly None/Status/OS (the removed "Team" option had no backing host field). When a non-None group is selected the rendered list MUST be partitioned into labelled sections via groupHosts(): Status groups by host monitoring band worst-first (critical, down, degraded, online, maintenance, unknown); OS groups by the host OS label alphabetically with the "Unknown" catch-all last. Empty groups are omitted; group=none renders the flat list unchanged. Grouping is applied AFTER search + filters + sort' + type: technical + enforcement: error + - id: C-11 + description: 'v1.7.0 — the Filters button MUST open a popover offering multi-select Status (monitoring band), Compliance tier (crit <40 / warn 40-80 / ok >=80 / none = never scanned), and Operating-system (families present in the fleet) facets. Selections MUST be persisted to the URL (status/os/tier comma-joined params) so a refresh restores them, and applied client-side by applyHostFilters as AND across dimensions / OR within a dimension. The button MUST show the active-filter count and the popover MUST offer Clear all. An empty dimension imposes no constraint' + type: technical + enforcement: error + - id: C-12 + description: 'v1.7.0 — the card/table view is chosen as: URL ?view= (table|cards) when present (so a link or refresh is stable), otherwise the user''s server-persisted default usePreferencesStore.hostsViewDefault (system-user-preferences). Toggling the view MUST both call setHostsViewDefault(v) (persisting the new per-user default) AND set ?view= for the session — so the choice "becomes the default until the user changes it"' + type: technical + enforcement: error acceptance_criteria: - id: AC-01 @@ -164,3 +180,21 @@ spec: - id: AC-21 description: 'v1.6.0 — the page has NO demo/fixture fallback. Hosts always come from GET /api/v1/hosts mapped via apiHostToDev; KPIs (kpisFromHosts) and the fleet alert (fleetAlertFromHosts) always compute from that real data; an empty fleet renders the honest empty state. There is no isDevFixturesEnabled / devHosts / devKpis / __ow_dev_fixtures code path anywhere, and bootstrapAuth has no __ow_dev_admin synthetic-identity bypass. Source-inspection: HostsListPage.tsx + auth-bootstrap.ts contain none of those identifiers.' priority: high + + - id: AC-22 + description: 'v1.7.0 — the ViewReportButton renders a router Link to "/scans/$scanId" with params scanId=latestScanId, and is used in BOTH the host card footer and the table row actions. It returns null when latestScanId is falsy OR useAuthStore hasPermission("scan:read") is false (so a host with no completed scan, or a viewer without scan:read, shows no chart icon). apiHostToDev maps latest_scan_id to DevHost.latestScanId. Source-inspection of HostsListPage.tsx + host-view-model.ts.' + priority: high + references_constraints: [C-09] + + - id: AC-23 + description: 'v1.7.0 — groupHosts(hosts, "none") returns one section holding the input unchanged. groupHosts(hosts, "status") returns sections in worst-first band order (critical before down before degraded before online before maintenance before unknown), each holding only its band, omitting empty bands. groupHosts(hosts, "os") returns OS-labelled sections alphabetically with "Unknown" last. The GroupSeg control offers exactly None/Status/OS (no "team"). Behavioral unit test over host-filtering.ts + source-inspection that the GroupSeg options array has no team entry.' + priority: high + references_constraints: [C-10] + - id: AC-24 + description: 'v1.7.0 — applyHostFilters(hosts, filters) keeps a host only when it matches every active dimension (AND across status/os/tier, OR within each), and an empty dimension imposes no constraint. hostComplianceTier buckets compliance as crit (<40) / warn (40-80) / ok (>=80) / none (compliance null = never scanned, NOT crit). parseHostFilters splits comma-joined URL params; activeFilterCount sums the selected facets. The FiltersControl popover wires these (Status/Compliance/OS facets + Clear all) and persists selections through onChange to the URL. Behavioral unit test over host-filtering.ts + source-inspection of FiltersControl.' + priority: high + references_constraints: [C-11] + - id: AC-25 + description: "v1.7.0 source-inspection: the view resolves to search.view when it is 'table' or 'cards', otherwise usePreferencesStore.hostsViewDefault (not a hardcoded 'cards'). The ViewToggle onChange calls setHostsViewDefault(v) AND updateSearch({view:v}) so the toggle persists the per-user default and updates the URL together." + priority: high + references_constraints: [C-12] diff --git a/specs/frontend/settings.spec.yaml b/specs/frontend/settings.spec.yaml index 39ef76f42..e125ddac9 100644 --- a/specs/frontend/settings.spec.yaml +++ b/specs/frontend/settings.spec.yaml @@ -1,7 +1,7 @@ spec: id: frontend-settings title: Settings — two-pane shell with 11 sub-pages - version: "1.10.0" + version: "1.11.0" status: draft tier: 2 @@ -115,9 +115,15 @@ spec: - id: C-06 description: >- Preferences MUST persist to localStorage under key - "ow-preferences" (via Zustand persist middleware). No backend - POST is made. Reloading the browser restores the same - selections + "ow-preferences" (via Zustand persist middleware) as an instant-load + cache. v2.0.0 — they ALSO persist server-side per user + (system-user-preferences): each setter writes through to + PATCH /api/v1/users/me/preferences, and the authenticated shell + reconciles local state with GET /api/v1/users/me/preferences on + mount (hydrateFromServer), so a selection follows the user across + devices. Server write-through is best-effort — a failed PATCH leaves + the localStorage value in place. Reloading the browser restores the + same selections type: technical enforcement: error - id: C-07 @@ -357,3 +363,8 @@ spec: description: "v1.10.0 source-inspection: a 409 from disabling your own account is surfaced inline through the shared action-error Callout (apiErrorMessage carries the users.cannot_disable_self reason); admin reset-password and disable/enable copy contains no em-dash characters." priority: medium references_constraints: [C-08] + + - id: AC-30 + description: "v1.11.0 source-inspection: usePreferencesStore keeps the Zustand persist('ow-preferences') localStorage cache AND syncs server-side — every setter calls push() which PATCHes /api/v1/users/me/preferences (best-effort, errors swallowed), and hydrateFromServer() GETs /api/v1/users/me/preferences and applies present keys to state. AppFrame calls hydrateFromServer once on mount via useEffect, so the authenticated shell reconciles preferences with the account." + priority: high + references_constraints: [C-06] diff --git a/specs/system/user-preferences.spec.yaml b/specs/system/user-preferences.spec.yaml new file mode 100644 index 000000000..2c2684c7f --- /dev/null +++ b/specs/system/user-preferences.spec.yaml @@ -0,0 +1,84 @@ +spec: + id: system-user-preferences + title: Per-user UI preferences — server-side persistence + self-scoped HTTP surface + version: "1.0.0" + status: approved + tier: 2 + + context: + system: openwatch-go + feature: Per-user UI preferences that follow the user across devices + description: > + Personal UI preferences (the /hosts grid-vs-table default, density, + accent colour, landing page, date format, reduce-motion) persisted + server-side on the users row so they follow a user across browsers and + devices instead of living only in localStorage. Stored as a single + JSONB blob (users.preferences, migration 0040); the valid key set is + governed by the typed api.UserPreferences contract. The HTTP surface is + self-scoped: GET/PATCH /api/v1/users/me/preferences read and merge ONLY + the calling identity's own preferences — the user id comes from the + session/bearer identity, never a path param, so there is no object-level + authorization to get wrong. + related_specs: + - system-user-management + - api-hosts + - frontend-hosts-list + + objective: + summary: >- + Lock the server-side per-user preferences store: JSONB storage on the + users row, a shallow merge that retains untouched keys, the + self-scoped HTTP surface (identity-derived user id, 401 for anonymous, + no RBAC permission), and the typed-contract validation that drops + unknown keys and rejects out-of-range enums. + scope: + includes: + - "users.preferences JSONB column (migration 0040, default '{}')" + - "Get returns '{}' for unset; ErrUserNotFound for unknown/deleted user" + - "Merge via JSONB || (shallow, scoped to one active user); non-object patch rejected" + - "GET/PATCH /api/v1/users/me/preferences self-scoped; anonymous -> 401 auth.required" + - "PATCH validates enum values (400) and drops unknown keys via the typed contract" + excludes: + - "The frontend store wiring / hydration (frontend-settings C-06)" + - "Per-preference defaults and their UI (the client owns fallbacks)" + - "Cross-device real-time sync / push (reconcile is on shell mount only)" + + constraints: + - id: C-01 + description: Preferences are stored as the users.preferences JSONB column (migration 0040, NOT NULL DEFAULT '{}'). The service Get returns the stored blob, or '{}' for a user who has never set one; an unknown or soft-deleted user yields ErrUserNotFound (never a fabricated empty object) + type: technical + enforcement: error + - id: C-02 + description: Merge performs a SHALLOW merge of the patch onto the stored blob via the Postgres JSONB '||' operator (top-level keys in the patch overwrite, omitted keys are retained) scoped to one active user (WHERE id = $1 AND deleted_at IS NULL), and returns the merged result. A non-object patch MUST be rejected before it reaches the column, never replacing the object with a scalar + type: technical + enforcement: error + - id: C-03 + description: The HTTP surface is self-scoped and authentication-gated. The user id is resolved from the request identity (auth.FromContext), NOT a path/query param. An anonymous caller MUST receive 401 auth.required; an authenticated caller needs NO additional RBAC permission to read or update its own preferences + type: security + enforcement: error + - id: C-04 + description: PATCH MUST only persist known, well-typed keys. Unknown keys are dropped at decode (the typed contract has no catch-all), and an out-of-range enum value (e.g. hosts_view_default other than table|cards) MUST be rejected 400 before any write. GET and PATCH responses are projected through the typed api.UserPreferences contract so only known keys surface + type: technical + enforcement: error + + acceptance_criteria: + - id: AC-01 + description: GET /api/v1/users/me/preferences as an authenticated user who has never set a preference returns 200 with an empty object {} (every key absent), not an error. + priority: high + references_constraints: [C-01] + - id: AC-02 + description: 'PATCH /api/v1/users/me/preferences with {"hosts_view_default":"table"} returns 200 echoing that value; a subsequent PATCH with {"density":"compact"} returns 200 with BOTH keys present (hosts_view_default retained from the first call) — proving the shallow merge, not a full overwrite.' + priority: critical + references_constraints: [C-02] + - id: AC-03 + description: 'PATCH /api/v1/users/me/preferences with an invalid enum value (e.g. {"hosts_view_default":"weird"}) returns 400 validation.invalid_value and does NOT persist — a following GET still reflects only previously-valid values.' + priority: high + references_constraints: [C-04] + - id: AC-04 + description: GET and PATCH /api/v1/users/me/preferences by an anonymous caller (no session, no bearer) both return 401 auth.required — never 200, never a 500. + priority: critical + references_constraints: [C-03] + - id: AC-05 + description: The userpref service Merge against an unknown user id returns ErrUserNotFound (no row updated), and Get against an unknown user id returns ErrUserNotFound; Get for a real user with the default column returns the '{}' object. + priority: high + references_constraints: [C-01, C-02] diff --git a/specter.yaml b/specter.yaml index 974a4aab6..376654298 100644 --- a/specter.yaml +++ b/specter.yaml @@ -87,6 +87,7 @@ domains: - api-notifications - system-api-tokens - api-tokens + - system-user-preferences - system-auth-policy - api-auth-policy - system-sso