chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-17 20:03:47 +12:00
parent 5639dbcb83
commit f852e7c941
127 changed files with 17928 additions and 10741 deletions
+252 -243
View File
@@ -1,137 +1,135 @@
'use client'
"use client";
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type DiagnosticCatalogItem = {
key: string
label: string
category: string
description: string
live_safe: boolean
target: string | null
configured: boolean
config_status: string
config_detail: string
}
key: string;
label: string;
category: string;
description: string;
live_safe: boolean;
target: string | null;
configured: boolean;
config_status: string;
config_detail: string;
};
type DiagnosticResult = {
key: string
label: string
category: string
description: string
target: string | null
live_safe: boolean
configured: boolean
status: string
message: string
detail?: unknown
checked_at?: string
duration_ms?: number
}
key: string;
label: string;
category: string;
description: string;
target: string | null;
live_safe: boolean;
configured: boolean;
status: string;
message: string;
detail?: unknown;
checked_at?: string;
duration_ms?: number;
};
type DiagnosticsResponse = {
checks: DiagnosticCatalogItem[]
categories: string[]
generated_at: string
}
checks: DiagnosticCatalogItem[];
categories: string[];
generated_at: string;
};
type RunDiagnosticsResponse = {
results: DiagnosticResult[]
results: DiagnosticResult[];
summary: {
total: number
up: number
down: number
degraded: number
not_configured: number
disabled: number
}
checked_at: string
}
total: number;
up: number;
down: number;
degraded: number;
not_configured: number;
disabled: number;
};
checked_at: string;
};
type RunMode = 'safe' | 'all' | 'single'
type RunMode = "safe" | "all" | "single";
type AdminDiagnosticsPanelProps = {
embedded?: boolean
}
embedded?: boolean;
};
type DatabaseDiagnosticDetail = {
integrity_check?: string
database_path?: string
database_size_bytes?: number
wal_size_bytes?: number
shm_size_bytes?: number
page_size_bytes?: number
page_count?: number
freelist_pages?: number
allocated_bytes?: number
free_bytes?: number
row_counts?: Record<string, number>
timings_ms?: Record<string, number>
}
integrity_check?: string;
database_path?: string;
database_size_bytes?: number;
wal_size_bytes?: number;
shm_size_bytes?: number;
page_size_bytes?: number;
page_count?: number;
freelist_pages?: number;
allocated_bytes?: number;
free_bytes?: number;
row_counts?: Record<string, number>;
timings_ms?: Record<string, number>;
};
const REFRESH_INTERVAL_MS = 30000
const REFRESH_INTERVAL_MS = 30000;
const STATUS_LABELS: Record<string, string> = {
idle: 'Ready',
up: 'Up',
down: 'Down',
degraded: 'Degraded',
disabled: 'Disabled',
not_configured: 'Not configured',
}
idle: "Ready",
up: "Up",
down: "Down",
degraded: "Degraded",
disabled: "Disabled",
not_configured: "Not configured",
};
function formatCheckedAt(value?: string) {
if (!value) return 'Not yet run'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return parsed.toLocaleString()
if (!value) return "Not yet run";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
function formatDuration(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) {
return 'Pending'
if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
return "Pending";
}
return `${value.toFixed(1)} ms`
return `${value.toFixed(1)} ms`;
}
function statusLabel(status: string) {
return STATUS_LABELS[status] ?? status
return STATUS_LABELS[status] ?? status;
}
function formatBytes(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
return '0 B'
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
return "0 B";
}
if (value >= 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)} MB`
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024).toFixed(1)} KB`;
}
return `${value} B`
return `${value} B`;
}
function formatDetailLabel(value: string) {
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (character) => character.toUpperCase())
return value.replace(/_/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) {
return null
if (!detail || typeof detail !== "object" || Array.isArray(detail)) {
return null;
}
return detail as DatabaseDiagnosticDetail
return detail as DatabaseDiagnosticDetail;
}
function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
if (values.length === 0) {
return null
return null;
}
return (
<div className="diagnostic-detail-group">
@@ -145,153 +143,157 @@ function renderDatabaseMetricGroup(title: string, values: Array<[string, string]
))}
</div>
</div>
)
);
}
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [authorized, setAuthorized] = useState(false)
const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([])
const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({})
const [runningKeys, setRunningKeys] = useState<string[]>([])
const [autoRefresh, setAutoRefresh] = useState(true)
const [pageError, setPageError] = useState('')
const [lastRunAt, setLastRunAt] = useState<string | null>(null)
const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null)
const [emailRecipient, setEmailRecipient] = useState('')
const router = useRouter();
const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([]);
const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({});
const [runningKeys, setRunningKeys] = useState<string[]>([]);
const [autoRefresh, setAutoRefresh] = useState(true);
const [pageError, setPageError] = useState("");
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null);
const [emailRecipient, setEmailRecipient] = useState("");
const liveSafeKeys = checks.filter((check) => check.live_safe).map((check) => check.key)
const liveSafeKeys = useMemo(() => checks.filter((check) => check.live_safe).map((check) => check.key), [checks]);
async function runDiagnostics(keys?: string[], mode: RunMode = 'single') {
const baseUrl = getApiBase()
const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key)
if (effectiveKeys.length === 0) {
return
}
setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])))
setPageError('')
try {
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
keys: effectiveKeys,
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
}),
})
if (response.status === 401) {
clearToken()
router.push('/login')
return
const runDiagnostics = useCallback(
async (keys?: string[], mode: RunMode = "single") => {
const baseUrl = getApiBase();
const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key);
if (effectiveKeys.length === 0) {
return;
}
if (!response.ok) {
const text = await response.text()
throw new Error(text || `Diagnostics run failed: ${response.status}`)
setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])));
setPageError("");
try {
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: effectiveKeys,
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
}),
});
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Diagnostics run failed: ${response.status}`);
}
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse;
const nextResults: Record<string, DiagnosticResult> = {};
for (const result of data.results ?? []) {
nextResults[result.key] = result;
}
setResultsByKey((current) => ({ ...current, ...nextResults }));
setLastRunAt(data.checked_at ?? new Date().toISOString());
setLastRunMode(mode);
} catch (error) {
console.error(error);
setPageError(error instanceof Error ? error.message : "Diagnostics run failed.");
} finally {
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)));
}
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse
const nextResults: Record<string, DiagnosticResult> = {}
for (const result of data.results ?? []) {
nextResults[result.key] = result
}
setResultsByKey((current) => ({ ...current, ...nextResults }))
setLastRunAt(data.checked_at ?? new Date().toISOString())
setLastRunMode(mode)
} catch (error) {
console.error(error)
setPageError(error instanceof Error ? error.message : 'Diagnostics run failed.')
} finally {
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)))
}
}
},
[checks, emailRecipient, router],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: Authorization bootstrap runs once for each router instance.
useEffect(() => {
let active = true
let active = true;
const loadPage = async () => {
if (!getToken()) {
router.push('/login')
return
router.push("/login");
return;
}
try {
const baseUrl = getApiBase()
const authResponse = await authFetch(`${baseUrl}/auth/me`)
const baseUrl = getApiBase();
const authResponse = await authFetch(`${baseUrl}/auth/me`);
if (!authResponse.ok) {
if (authResponse.status === 401) {
clearToken()
router.push('/login')
return
clearToken();
router.push("/login");
return;
}
router.push('/')
return
router.push("/");
return;
}
const me = await authResponse.json()
if (!active) return
if (me?.role !== 'admin') {
router.push('/')
return
const me = await authResponse.json();
if (!active) return;
if (me?.role !== "admin") {
router.push("/");
return;
}
const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`)
const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`);
if (!diagnosticsResponse.ok) {
const text = await diagnosticsResponse.text()
throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`)
const text = await diagnosticsResponse.text();
throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`);
}
const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse
if (!active) return
setChecks(data.checks ?? [])
setAuthorized(true)
setLoading(false)
const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key)
const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse;
if (!active) return;
setChecks(data.checks ?? []);
setAuthorized(true);
setLoading(false);
const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key);
if (safeKeys.length > 0) {
void runDiagnostics(safeKeys, 'safe')
void runDiagnostics(safeKeys, "safe");
}
} catch (error) {
console.error(error)
if (!active) return
setPageError(error instanceof Error ? error.message : 'Unable to load diagnostics.')
setLoading(false)
console.error(error);
if (!active) return;
setPageError(error instanceof Error ? error.message : "Unable to load diagnostics.");
setLoading(false);
}
}
};
void loadPage()
void loadPage();
return () => {
active = false
}
}, [router])
active = false;
};
}, [router]);
useEffect(() => {
if (!authorized || !autoRefresh || liveSafeKeys.length === 0) {
return
return;
}
const interval = window.setInterval(() => {
void runDiagnostics(liveSafeKeys, 'safe')
}, REFRESH_INTERVAL_MS)
void runDiagnostics(liveSafeKeys, "safe");
}, REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(interval)
}
}, [authorized, autoRefresh, liveSafeKeys.join('|')])
window.clearInterval(interval);
};
}, [authorized, autoRefresh, liveSafeKeys, runDiagnostics]);
if (loading) {
return <div className="admin-panel">Loading diagnostics...</div>
return <div className="admin-panel">Loading diagnostics...</div>;
}
if (!authorized) {
return null
return null;
}
const orderedCategories: string[] = []
const orderedCategories: string[] = [];
for (const check of checks) {
if (!orderedCategories.includes(check.category)) {
orderedCategories.push(check.category)
orderedCategories.push(check.category);
}
}
const mergedResults = checks.map((check) => {
const result = resultsByKey[check.key]
const result = resultsByKey[check.key];
if (result) {
return result
return result;
}
return {
key: check.key,
@@ -301,12 +303,12 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
target: check.target,
live_safe: check.live_safe,
configured: check.configured,
status: check.configured ? 'idle' : check.config_status,
message: check.configured ? 'Ready to test.' : check.config_detail,
status: check.configured ? "idle" : check.config_status,
message: check.configured ? "Ready to test." : check.config_detail,
checked_at: undefined,
duration_ms: undefined,
} satisfies DiagnosticResult
})
} satisfies DiagnosticResult;
});
const summary = {
total: mergedResults.length,
@@ -316,47 +318,46 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
disabled: 0,
not_configured: 0,
idle: 0,
}
};
for (const result of mergedResults) {
const key = result.status as keyof typeof summary
const key = result.status as keyof typeof summary;
if (key in summary) {
summary[key] += 1
summary[key] += 1;
}
}
return (
<div className={`diagnostics-page${embedded ? ' diagnostics-page-embedded' : ''}`}>
<div className={`diagnostics-page${embedded ? " diagnostics-page-embedded" : ""}`}>
<div className="admin-panel diagnostics-control-panel">
<div className="diagnostics-control-copy">
<h2>{embedded ? 'Connectivity diagnostics' : 'Control center'}</h2>
<h2>{embedded ? "Connectivity diagnostics" : "Control center"}</h2>
<p className="lede">
Check Magent and your connected services. Automatic refresh runs health checks only.
Test messages are managed in Notifications below.
Check Magent and your connected services. Automatic refresh runs health checks only. Test messages are
managed in Notifications below.
</p>
</div>
<div className="diagnostics-control-actions">
<button
type="button"
className={autoRefresh ? 'is-active' : ''}
className={autoRefresh ? "is-active" : ""}
onClick={() => setAutoRefresh((current) => !current)}
>
{autoRefresh ? 'Disable auto refresh' : 'Enable auto refresh'}
{autoRefresh ? "Disable auto refresh" : "Enable auto refresh"}
</button>
<button
type="button"
onClick={() => {
void runDiagnostics(liveSafeKeys, 'safe')
void runDiagnostics(liveSafeKeys, "safe");
}}
disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
>
Run live checks
</button>
<span className={`small-pill ${autoRefresh ? 'is-positive' : ''}`}>
{autoRefresh ? 'Auto refresh on' : 'Auto refresh off'}
<span className={`small-pill ${autoRefresh ? "is-positive" : ""}`}>
{autoRefresh ? "Auto refresh on" : "Auto refresh off"}
</span>
<span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : 'No run yet'}</span>
<span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : "No run yet"}</span>
</div>
</div>
@@ -385,39 +386,47 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<span>Not configured</span>
<strong>{summary.not_configured}</strong>
</div>
<div className="diagnostics-inline-last-run">
Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}
</div>
<div className="diagnostics-inline-last-run">Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}</div>
</div>
{pageError ? <div className="admin-panel diagnostics-error">{pageError}</div> : null}
{orderedCategories.map((category) => {
const categoryChecks = mergedResults.filter((check) => check.category === category)
const categoryChecks = mergedResults.filter((check) => check.category === category);
return (
<div key={category} className="admin-panel diagnostics-category-panel">
<div className="diagnostics-category-header">
<div>
<h2>{category}</h2>
<p>{category === 'Notifications' ? 'These tests can emit real messages.' : 'Safe live health checks.'}</p>
<p>
{category === "Notifications" ? "These tests can emit real messages." : "Safe live health checks."}
</p>
</div>
<span className="small-pill">{categoryChecks.length} checks</span>
</div>
{category === 'Notifications' && (
{category === "Notifications" && (
<div className="diagnostics-notification-controls">
<label className="diagnostics-email-recipient">
<span>Test email recipient</span>
<input
type="email"
placeholder="Leave blank to use configured sender"
value={emailRecipient}
onChange={(event) => setEmailRecipient(event.target.value)}
/>
</label>
<label className="diagnostics-email-recipient">
<span>Test email recipient</span>
<input
type="email"
placeholder="Leave blank to use configured sender"
value={emailRecipient}
onChange={(event) => setEmailRecipient(event.target.value)}
/>
</label>
<p>Choose where the test email goes. Other channels use their configured destinations.</p>
<button type="button" disabled={runningKeys.length > 0 || categoryChecks.length === 0}
onClick={() => void runDiagnostics(categoryChecks.map(check => check.key), 'all')}>
<button
type="button"
disabled={runningKeys.length > 0 || categoryChecks.length === 0}
onClick={() =>
void runDiagnostics(
categoryChecks.map((check) => check.key),
"all",
)
}
>
Test all notification channels
</button>
</div>
@@ -425,7 +434,7 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<div className="diagnostics-grid">
{categoryChecks.map((check) => {
const isRunning = runningKeys.includes(check.key)
const isRunning = runningKeys.includes(check.key);
return (
<article key={check.key} className={`diagnostic-card diagnostic-card-${check.status}`}>
<div className="diagnostic-card-top">
@@ -440,18 +449,18 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
type="button"
className="system-test"
onClick={() => {
void runDiagnostics([check.key], 'single')
void runDiagnostics([check.key], "single");
}}
disabled={isRunning}
>
{check.live_safe ? 'Ping' : 'Send test'}
{check.live_safe ? "Ping" : "Send test"}
</button>
</div>
<div className="diagnostic-meta-grid">
<div className="diagnostic-meta-item">
<span>Target</span>
<strong>{check.target || 'Not set'}</strong>
<strong>{check.target || "Not set"}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Latency</span>
@@ -459,7 +468,7 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
</div>
<div className="diagnostic-meta-item">
<span>Mode</span>
<strong>{check.live_safe ? 'Live safe' : 'Manual only'}</strong>
<strong>{check.live_safe ? "Live safe" : "Manual only"}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Last checked</span>
@@ -469,53 +478,53 @@ export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnos
<div className={`diagnostic-message diagnostic-message-${check.status}`}>
<span className="system-dot" />
<span>{isRunning ? 'Running diagnostic...' : check.message}</span>
<span>{isRunning ? "Running diagnostic..." : check.message}</span>
</div>
{check.key === 'database'
{check.key === "database"
? (() => {
const detail = asDatabaseDiagnosticDetail(check.detail)
const detail = asDatabaseDiagnosticDetail(check.detail);
if (!detail) {
return null
return null;
}
return (
<details className="diagnostic-detail-panel">
<summary>Database storage, tables and timings</summary>
{renderDatabaseMetricGroup('Storage', [
['Database file', formatBytes(detail.database_size_bytes)],
['WAL file', formatBytes(detail.wal_size_bytes)],
['Shared memory', formatBytes(detail.shm_size_bytes)],
['Allocated bytes', formatBytes(detail.allocated_bytes)],
['Free bytes', formatBytes(detail.free_bytes)],
['Page size', formatBytes(detail.page_size_bytes)],
['Page count', `${detail.page_count?.toLocaleString() ?? 0}`],
['Freelist pages', `${detail.freelist_pages?.toLocaleString() ?? 0}`],
{renderDatabaseMetricGroup("Storage", [
["Database file", formatBytes(detail.database_size_bytes)],
["WAL file", formatBytes(detail.wal_size_bytes)],
["Shared memory", formatBytes(detail.shm_size_bytes)],
["Allocated bytes", formatBytes(detail.allocated_bytes)],
["Free bytes", formatBytes(detail.free_bytes)],
["Page size", formatBytes(detail.page_size_bytes)],
["Page count", `${detail.page_count?.toLocaleString() ?? 0}`],
["Freelist pages", `${detail.freelist_pages?.toLocaleString() ?? 0}`],
])}
{renderDatabaseMetricGroup(
'Tables',
"Tables",
Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
formatDetailLabel(key),
value.toLocaleString(),
]),
)}
{renderDatabaseMetricGroup(
'Timings',
"Timings",
Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
formatDetailLabel(key),
`${value.toFixed(1)} ms`,
]),
)}
</details>
)
);
})()
: null}
</article>
)
);
})}
</div>
</div>
)
);
})}
</div>
)
);
}
+17 -12
View File
@@ -1,16 +1,16 @@
'use client'
"use client";
import type { ReactNode } from 'react'
import SettingsNavigation from './SettingsNavigation'
import PageHeading from './PageHeading'
import type { ReactNode } from "react";
import SettingsNavigation from "./SettingsNavigation";
import PageHeading from "./PageHeading";
type AdminShellProps = {
title: string
subtitle?: string
actions?: ReactNode
rail?: ReactNode
children: ReactNode
}
title: string;
subtitle?: string;
actions?: ReactNode;
rail?: ReactNode;
children: ReactNode;
};
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
return (
@@ -19,8 +19,13 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
<main className="card admin-card">
<PageHeading title={title} description={subtitle} actions={actions} />
{children}
{rail && <details className="admin-supplemental"><summary>Additional information</summary>{rail}</details>}
{rail && (
<details className="admin-supplemental">
<summary>Additional information</summary>
{rail}
</details>
)}
</main>
</div>
)
);
}
+51 -21
View File
@@ -1,25 +1,55 @@
'use client'
"use client";
import { usePathname } from 'next/navigation'
import BrandingLogo from './BrandingLogo'
import HeaderActions from './HeaderActions'
import HeaderIdentity from './HeaderIdentity'
import GlobalSearch from './GlobalSearch'
import SiteStatus from './SiteStatus'
import UserViewBanner from './UserViewBanner'
import WorkspaceNavigation from './WorkspaceNavigation'
import { usePathname } from "next/navigation";
import BrandingLogo from "./BrandingLogo";
import HeaderActions from "./HeaderActions";
import HeaderIdentity from "./HeaderIdentity";
import GlobalSearch from "./GlobalSearch";
import SiteStatus from "./SiteStatus";
import UserViewBanner from "./UserViewBanner";
import WorkspaceNavigation from "./WorkspaceNavigation";
export default function ApplicationChrome() {
const pathname = usePathname()
if (['/welcome', '/coming-soon', '/login', '/forgot-password', '/reset-password', '/signup', '/email-recaps', '/newsletter-subscription'].includes(pathname)) return null
return <>
<header className="header">
<div className="header-left"><a className="brand-link" href="/"><BrandingLogo className="brand-logo brand-logo--header" /><div className="brand-stack"><div className="brand">Magent</div><div className="tagline">GrizzlyFlix media operations</div></div></a></div>
<div className="header-right"><span className="beta-chip" title="Beta environment">Beta</span><HeaderIdentity /></div>
<div className="header-nav"><GlobalSearch /><HeaderActions /></div>
</header>
<WorkspaceNavigation />
<UserViewBanner />
<SiteStatus />
</>
const pathname = usePathname();
if (
[
"/welcome",
"/coming-soon",
"/login",
"/forgot-password",
"/reset-password",
"/signup",
"/email-recaps",
"/newsletter-subscription",
].includes(pathname)
)
return null;
return (
<>
<header className="header">
<div className="header-left">
<a className="brand-link" href="/">
<BrandingLogo className="brand-logo brand-logo--header" />
<div className="brand-stack">
<div className="brand">Magent</div>
<div className="tagline">GrizzlyFlix media operations</div>
</div>
</a>
</div>
<div className="header-right">
<span className="beta-chip" title="Beta environment">
Beta
</span>
<HeaderIdentity />
</div>
<div className="header-nav">
<GlobalSearch />
<HeaderActions />
</div>
</header>
<WorkspaceNavigation />
<UserViewBanner />
<SiteStatus />
</>
);
}
+24 -10
View File
@@ -1,21 +1,35 @@
import type { ReactNode } from 'react'
import MagentMark from './MagentMark'
import type { ReactNode } from "react";
import MagentMark from "./MagentMark";
export default function AuthLayout({ title, description, children, footer }: {
title: string
description: string
children: ReactNode
footer?: ReactNode
export default function AuthLayout({
title,
description,
children,
footer,
}: {
title: string;
description: string;
children: ReactNode;
footer?: ReactNode;
}) {
return (
<main className="login-page">
<section className="login-card" aria-labelledby="login-title">
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
<header><h1 id="login-title">{title}</h1><p>{description}</p></header>
<div className="login-brand">
<a href="/login" aria-label="Magent sign in">
<MagentMark />
<span>Magent</span>
</a>
<span className="login-beta">Beta</span>
</div>
<header>
<h1 id="login-title">{title}</h1>
<p>{description}</p>
</header>
{children}
{footer && <footer>{footer}</footer>}
</section>
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
</main>
)
);
}
+10 -10
View File
@@ -1,18 +1,18 @@
'use client'
"use client";
import { useEffect } from 'react'
import { useEffect } from "react";
export default function BrandingFavicon() {
useEffect(() => {
const href = '/api/branding/favicon.ico'
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null
const href = "/api/branding/favicon.ico";
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null;
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
link.href = href
}, [])
link.href = href;
}, []);
return null
return null;
}
+12 -15
View File
@@ -1,21 +1,21 @@
'use client'
"use client";
import { useState } from 'react'
import { useState } from "react";
type BrandingLogoProps = {
className?: string
alt?: string
}
className?: string;
alt?: string;
};
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
const [loaded, setLoaded] = useState(false)
const [failed, setFailed] = useState(false)
export default function BrandingLogo({ className, alt = "Magent logo" }: BrandingLogoProps) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
return (
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
<span className={`${className ?? ""} branding-logo-shell`} role="img" aria-label={alt}>
{!failed ? (
<img
className={loaded ? 'is-loaded' : undefined}
className={loaded ? "is-loaded" : undefined}
src="/api/branding/logo.png"
alt=""
aria-hidden="true"
@@ -33,12 +33,9 @@ export default function BrandingLogo({ className, alt = 'Magent logo' }: Brandin
</defs>
<rect width="64" height="64" rx="12" fill="#0b1328" />
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
<path
d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z"
fill="url(#magentLogoGlow)"
/>
<path d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z" fill="url(#magentLogoGlow)" />
</svg>
) : null}
</span>
)
);
}
+45 -27
View File
@@ -1,37 +1,55 @@
'use client'
"use client";
import { usePathname } from 'next/navigation'
import { useEffect, useState, type ReactNode } from 'react'
import { authFetch, getApiBase, getToken } from '../lib/auth'
import { canAccess, featureForPath, type FeatureAccess } from '../lib/features'
import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { authFetch, getApiBase, getToken } from "../lib/auth";
import { canAccess, featureForPath, type FeatureAccess } from "../lib/features";
export function useFeatureUser() {
const pathname = usePathname()
const [state, setState] = useState<{ path: string; user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null }>({ path: '', user: null })
const pathname = usePathname();
const [state, setState] = useState<{
path: string;
user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null;
}>({ path: "", user: null });
useEffect(() => {
let active = true
let active = true;
const load = async () => {
if (!getToken()) { if (active) setState({ path: pathname, user: null }); return }
if (!getToken()) {
if (active) setState({ path: pathname, user: null });
return;
}
try {
const response = await authFetch(`${getApiBase()}/auth/me`)
const user = response.ok ? await response.json() : null
if (active) setState({ path: pathname, user })
} catch { if (active) setState({ path: pathname, user: null }) }
}
void load()
window.addEventListener('focus', load)
return () => { active = false; window.removeEventListener('focus', load) }
}, [pathname])
return { user: state.user, ready: state.path === pathname }
const response = await authFetch(`${getApiBase()}/auth/me`);
const user = response.ok ? await response.json() : null;
if (active) setState({ path: pathname, user });
} catch {
if (active) setState({ path: pathname, user: null });
}
};
void load();
window.addEventListener("focus", load);
return () => {
active = false;
window.removeEventListener("focus", load);
};
}, [pathname]);
return { user: state.user, ready: state.path === pathname };
}
export default function FeatureGate({ children }: { children: ReactNode }) {
const pathname = usePathname()
const { user, ready } = useFeatureUser()
const feature = featureForPath(pathname)
if (!feature) return children
if (!ready) return <main className="card">Loading account access...</main>
if (!getToken()) return children
if (!canAccess(user, feature)) return <main className="card"><h1>Feature unavailable</h1><p>Your account does not have access to this feature. Ask an administrator if you need it enabled.</p><a href="/profile">Go to my profile</a></main>
return children
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const feature = featureForPath(pathname);
if (!feature) return children;
if (!ready) return <main className="card">Loading account access...</main>;
if (!getToken()) return children;
if (!canAccess(user, feature))
return (
<main className="card">
<h1>Feature unavailable</h1>
<p>Your account does not have access to this feature. Ask an administrator if you need it enabled.</p>
<a href="/profile">Go to my profile</a>
</main>
);
return children;
}
+135 -102
View File
@@ -1,146 +1,179 @@
'use client'
"use client";
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { authFetch, getApiBase } from '../lib/auth'
import { canAccess } from '../lib/features'
import { useFeatureUser } from './FeatureGate'
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../lib/auth";
import { canAccess } from "../lib/features";
import { useFeatureUser } from "./FeatureGate";
type SearchResult = {
title: string
year?: number | null
type: 'movie' | 'tv'
tmdbId: number
requestId?: number | null
statusLabel?: string | null
}
title: string;
year?: number | null;
type: "movie" | "tv";
tmdbId: number;
requestId?: number | null;
statusLabel?: string | null;
};
export default function GlobalSearch() {
const router = useRouter()
const { user, ready } = useFeatureUser()
const root = useRef<HTMLDivElement>(null)
const requestVersion = useRef(0)
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [open, setOpen] = useState(false)
const [searching, setSearching] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const canSearch = canAccess(user, 'new_requests') || canAccess(user, 'issues')
const canOpenRequests = canAccess(user, 'requests')
const canCreateRequests = canAccess(user, 'new_requests')
const router = useRouter();
const { user, ready } = useFeatureUser();
const root = useRef<HTMLDivElement>(null);
const requestVersion = useRef(0);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [open, setOpen] = useState(false);
const [searching, setSearching] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const canSearch = canAccess(user, "new_requests") || canAccess(user, "issues");
const canOpenRequests = canAccess(user, "requests");
const canCreateRequests = canAccess(user, "new_requests");
useEffect(() => {
const close = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false)
}
document.addEventListener('pointerdown', close)
return () => document.removeEventListener('pointerdown', close)
}, [])
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
useEffect(() => {
const term = query.trim()
requestVersion.current += 1
const version = requestVersion.current
const term = query.trim();
requestVersion.current += 1;
const version = requestVersion.current;
if (term.length < 2 || !canSearch) {
setResults([])
setSearching(false)
setMessage(null)
return
setResults([]);
setSearching(false);
setMessage(null);
return;
}
setSearching(true)
setMessage(null)
const controller = new AbortController()
setSearching(true);
setMessage(null);
const controller = new AbortController();
const timer = window.setTimeout(async () => {
try {
const params = new URLSearchParams({ query: term })
const params = new URLSearchParams({ query: term });
const response = await authFetch(`${getApiBase()}/requests/search?${params.toString()}`, {
signal: controller.signal,
cache: 'no-store',
})
if (!response.ok) throw new Error('Search is unavailable right now.')
const payload = await response.json()
if (version !== requestVersion.current) return
cache: "no-store",
});
if (!response.ok) throw new Error("Search is unavailable right now.");
const payload = await response.json();
if (version !== requestVersion.current) return;
const mapped = (Array.isArray(payload?.results) ? payload.results : [])
.filter((item: any) => ['movie', 'tv'].includes(item?.type) && Number(item?.tmdbId) > 0)
.filter(
(item: Record<string, unknown>) =>
typeof item.type === "string" && ["movie", "tv"].includes(item.type) && Number(item.tmdbId) > 0,
)
.slice(0, 7)
.map((item: any): SearchResult => ({
title: String(item?.title || 'Untitled'),
year: typeof item?.year === 'number' ? item.year : null,
type: item.type,
tmdbId: Number(item.tmdbId),
requestId: typeof item?.requestId === 'number' ? item.requestId : null,
statusLabel: typeof item?.statusLabel === 'string' ? item.statusLabel : null,
}))
setResults(mapped)
setMessage(mapped.length ? null : 'No matching titles found.')
.map(
(item: Record<string, unknown>): SearchResult => ({
title: String(item?.title || "Untitled"),
year: typeof item?.year === "number" ? item.year : null,
type: item.type === "tv" ? "tv" : "movie",
tmdbId: Number(item.tmdbId),
requestId: typeof item?.requestId === "number" ? item.requestId : null,
statusLabel: typeof item?.statusLabel === "string" ? item.statusLabel : null,
}),
);
setResults(mapped);
setMessage(mapped.length ? null : "No matching titles found.");
} catch (error) {
if (controller.signal.aborted || version !== requestVersion.current) return
console.error(error)
setResults([])
setMessage('Search is unavailable right now.')
if (controller.signal.aborted || version !== requestVersion.current) return;
console.error(error);
setResults([]);
setMessage("Search is unavailable right now.");
} finally {
if (version === requestVersion.current) setSearching(false)
if (version === requestVersion.current) setSearching(false);
}
}, 280)
}, 280);
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [query, canSearch])
window.clearTimeout(timer);
controller.abort();
};
}, [query, canSearch]);
if (!ready || !user || !canSearch) return null
if (!ready || !user || !canSearch) return null;
const openResult = (result: SearchResult) => {
setOpen(false)
setQuery('')
setOpen(false);
setQuery("");
if (result.requestId && canOpenRequests) {
router.push(`/requests/${result.requestId}`)
return
router.push(`/requests/${result.requestId}`);
return;
}
if (canCreateRequests) {
const params = new URLSearchParams({ type: result.type, query: result.title })
router.push(`/new-requests?${params.toString()}`)
return
const params = new URLSearchParams({ type: result.type, query: result.title });
router.push(`/new-requests?${params.toString()}`);
return;
}
router.push('/portal/issues')
}
router.push("/portal/issues");
};
const showResults = open && query.trim().length >= 2
const showResults = open && query.trim().length >= 2;
return (
<div className="global-search" ref={root}>
<search>
<form onSubmit={(event) => {
event.preventDefault()
if (results[0]) openResult(results[0])
}}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m16 16 4 4" /></svg>
<input
type="search"
role="combobox"
value={query}
placeholder="Search titles or requests"
aria-label="Search titles or requests"
aria-autocomplete="list"
aria-expanded={showResults}
aria-controls="global-search-results"
onFocus={() => setOpen(true)}
onKeyDown={(event) => { if (event.key === 'Escape') setOpen(false) }}
onChange={(event) => { setQuery(event.target.value); setOpen(true) }}
/>
{searching && <i className="global-search-spinner" aria-hidden="true" />}
</form>
<form
onSubmit={(event) => {
event.preventDefault();
if (results[0]) openResult(results[0]);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m16 16 4 4" />
</svg>
<input
type="search"
role="combobox"
value={query}
placeholder="Search titles or requests"
aria-label="Search titles or requests"
aria-autocomplete="list"
aria-expanded={showResults}
aria-controls="global-search-results"
onFocus={() => setOpen(true)}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false);
}}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
/>
{searching && <i className="global-search-spinner" aria-hidden="true" />}
</form>
</search>
{showResults && (
<div className="global-search-results" id="global-search-results" role="listbox">
{results.map((result) => (
<button type="button" role="option" aria-selected="false" key={`${result.type}:${result.tmdbId}`} onClick={() => openResult(result)}>
<span><strong>{result.title}</strong><small>{result.type === 'tv' ? 'TV show' : 'Movie'}{result.year ? ` · ${result.year}` : ''}</small></span>
<b>{result.requestId && canOpenRequests ? result.statusLabel || 'View request' : canCreateRequests ? 'New request' : 'Report issue'}</b>
<button
type="button"
role="option"
aria-selected="false"
key={`${result.type}:${result.tmdbId}`}
onClick={() => openResult(result)}
>
<span>
<strong>{result.title}</strong>
<small>
{result.type === "tv" ? "TV show" : "Movie"}
{result.year ? ` · ${result.year}` : ""}
</small>
</span>
<b>
{result.requestId && canOpenRequests
? result.statusLabel || "View request"
: canCreateRequests
? "New request"
: "Report issue"}
</b>
</button>
))}
{!searching && message && <p role="status">{message}</p>}
</div>
)}
</div>
)
);
}
+38 -41
View File
@@ -1,82 +1,79 @@
'use client'
"use client";
import { usePathname } from 'next/navigation'
import { canAccess, featureForPath } from '../lib/features'
import { useFeatureUser } from './FeatureGate'
import { usePathname } from "next/navigation";
import { canAccess, featureForPath } from "../lib/features";
import { useFeatureUser } from "./FeatureGate";
export default function HeaderActions() {
const pathname = usePathname()
const { user, ready } = useFeatureUser()
const role = user?.role ?? null
const showRequestsNav = canAccess(user, 'new_requests')
if (!ready || !user) return null
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const role = user?.role ?? null;
const showRequestsNav = canAccess(user, "new_requests");
if (!ready || !user) return null;
const roleItems =
role === null
? []
: role === 'admin'
: role === "admin"
? [
{
href: '/profile/invites',
label: 'Invites',
match: (path: string) => path.startsWith('/profile/invites'),
href: "/profile/invites",
label: "Invites",
match: (path: string) => path.startsWith("/profile/invites"),
},
{
href: '/admin',
label: 'Config',
match: (path: string) => path.startsWith('/admin') || path.startsWith('/users'),
href: "/admin",
label: "Config",
match: (path: string) => path.startsWith("/admin") || path.startsWith("/users"),
},
]
: [
{
href: '/profile/invites',
label: 'Invites',
match: (path: string) => path.startsWith('/profile/invites'),
href: "/profile/invites",
label: "Invites",
match: (path: string) => path.startsWith("/profile/invites"),
},
]
];
const commonItems = [
...(showRequestsNav
? [
{
href: '/new-requests',
label: 'New Requests',
match: (path: string) => path === '/new-requests',
href: "/new-requests",
label: "New Requests",
match: (path: string) => path === "/new-requests",
},
]
: []),
{
href: '/',
label: 'My Requests',
match: (path: string) => path === '/' || path.startsWith('/requests/'),
href: "/",
label: "My Requests",
match: (path: string) => path === "/" || path.startsWith("/requests/"),
},
{
href: '/insights',
label: 'My Stats',
match: (path: string) => path === '/insights' || path.startsWith('/insights/'),
href: "/insights",
label: "My Stats",
match: (path: string) => path === "/insights" || path.startsWith("/insights/"),
},
{
href: '/portal/issues',
label: 'Issues',
match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
href: "/portal/issues",
label: "Issues",
match: (path: string) => path === "/portal/issues" || path === "/admin/issues",
},
]
];
const items = [
...commonItems,
...roleItems,
].filter((item) => canAccess(user, featureForPath(item.href)))
const items = [...commonItems, ...roleItems].filter((item) => canAccess(user, featureForPath(item.href)));
return (
<nav className="header-actions" aria-label="Primary">
{items.map((item) => {
const active = item.match(pathname)
const active = item.match(pathname);
return (
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
<a key={item.href} href={item.href} className={active ? "is-active" : undefined}>
{item.label}
</a>
)
);
})}
</nav>
)
);
}
+49 -45
View File
@@ -1,78 +1,78 @@
'use client'
"use client";
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
import { useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken, logout } from "../lib/auth";
import { setUserViewPreview, useUserViewPreview } from "../lib/viewMode";
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
const [buildNumber, setBuildNumber] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const viewAsUser = useUserViewPreview()
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null);
const [buildNumber, setBuildNumber] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const viewAsUser = useUserViewPreview();
useEffect(() => {
const token = getToken()
const token = getToken();
if (!token) {
setIdentity(null)
setBuildNumber(null)
return
setIdentity(null);
setBuildNumber(null);
return;
}
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/me`);
if (!response.ok) {
clearToken()
setIdentity(null)
return
clearToken();
setIdentity(null);
return;
}
const data = await response.json()
const data = await response.json();
if (data?.username) {
setIdentity({ username: data.username, role: data.role })
if (data.role !== 'admin') {
setUserViewPreview(false)
setIdentity({ username: data.username, role: data.role });
if (data.role !== "admin") {
setUserViewPreview(false);
}
}
const siteResponse = await fetch(`${baseUrl}/site/public`)
const siteResponse = await fetch(`${baseUrl}/site/public`);
if (siteResponse.ok) {
const siteInfo = await siteResponse.json()
const siteInfo = await siteResponse.json();
if (siteInfo?.buildNumber) {
setBuildNumber(siteInfo.buildNumber)
setBuildNumber(siteInfo.buildNumber);
}
}
} catch (err) {
console.error(err)
setIdentity(null)
console.error(err);
setIdentity(null);
}
}
void load()
}, [])
};
void load();
}, []);
if (!identity) {
return null
return null;
}
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase()
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ""}`;
const initial = identity.username.slice(0, 1).toUpperCase();
const signOut = async () => {
setUserViewPreview(false)
await logout().catch(() => undefined)
clearToken()
if (typeof window !== 'undefined') {
window.location.href = '/login'
setUserViewPreview(false);
await logout().catch(() => undefined);
clearToken();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
}
};
return (
<div className="signed-in-context">
{identity.role === 'admin' ? (
{identity.role === "admin" ? (
<button
type="button"
className={`user-view-toggle ${viewAsUser ? 'is-active' : ''}`}
className={`user-view-toggle ${viewAsUser ? "is-active" : ""}`}
aria-pressed={viewAsUser}
onClick={() => setUserViewPreview(!viewAsUser)}
>
{viewAsUser ? 'Exit user view' : 'View as user'}
{viewAsUser ? "Exit user view" : "View as user"}
</button>
) : null}
<div className="signed-in-menu">
@@ -93,12 +93,16 @@ export default function HeaderIdentity() {
{viewAsUser ? <span>Previewing user view</span> : null}
</div>
<div className="signed-in-actions">
<a href="/welcome" onClick={() => setOpen(false)}>Welcome page</a>
<a href="/how-it-works" onClick={() => setOpen(false)}>How it works</a>
<a href="/welcome" onClick={() => setOpen(false)}>
Welcome page
</a>
<a href="/how-it-works" onClick={() => setOpen(false)}>
How it works
</a>
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
{identity.role === 'admin' ? (
{identity.role === "admin" ? (
<a href="/admin" onClick={() => setOpen(false)}>
Settings
</a>
@@ -112,5 +116,5 @@ export default function HeaderIdentity() {
)}
</div>
</div>
)
);
}
+51 -13
View File
@@ -1,17 +1,55 @@
'use client'
"use client";
import './invite-delivery.css'
import "./invite-delivery.css";
export default function InviteDeliveryChoice({ value, onChange }: {
value: 'manual' | 'email' | '' | null; onChange: (method: 'manual' | 'email') => void
export default function InviteDeliveryChoice({
value,
onChange,
}: {
value: "manual" | "email" | "" | null;
onChange: (method: "manual" | "email") => void;
}) {
return <div className="invite-delivery-options" role="group" aria-label="Invite delivery method">
{(['manual', 'email'] as const).map((method) => <button key={method} type="button" aria-pressed={value === method} onClick={() => onChange(method)}>
<span className="delivery-choice-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
{method === 'manual' ? <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2" /></> : <><rect x="3" y="5" width="18" height="14" rx="3" /><path d="m3 7 9 6 9-6" /></>}
</svg></span>
<span className="delivery-choice-copy"><strong>{method === 'manual' ? 'Copy a link' : 'Send an email'}</strong><small>{method === 'manual' ? 'They add their email when signing up.' : 'One use, tied to the recipients email. You get the link too.'}</small></span>
<span className="delivery-choice-check" aria-hidden="true">{value === method ? '✓' : ''}</span>
</button>)}
</div>
return (
<fieldset className="invite-delivery-options">
<legend className="sr-only">Invite delivery method</legend>
{(["manual", "email"] as const).map((method) => (
<button key={method} type="button" aria-pressed={value === method} onClick={() => onChange(method)}>
<span className="delivery-choice-icon" aria-hidden="true">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
{method === "manual" ? (
<>
<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2" />
<path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2" />
</>
) : (
<>
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="m3 7 9 6 9-6" />
</>
)}
</svg>
</span>
<span className="delivery-choice-copy">
<strong>{method === "manual" ? "Copy a link" : "Send an email"}</strong>
<small>
{method === "manual"
? "They add their email when signing up."
: "One use, tied to the recipients email. You get the link too."}
</small>
</span>
<span className="delivery-choice-check" aria-hidden="true">
{value === method ? "✓" : ""}
</span>
</button>
))}
</fieldset>
);
}
+6 -1
View File
@@ -1,3 +1,8 @@
export default function MagentMark() {
return <svg className="magent-mark" viewBox="0 0 40 40" fill="none" aria-hidden="true"><rect x=".5" y=".5" width="39" height="39" rx="11" fill="#242329" stroke="#45434f" /><path d="M10 29V11h4l6 9 6-9h4v18h-4V18l-6 8-6-8v11h-4Z" fill="#dedaff" /></svg>
return (
<svg className="magent-mark" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect x=".5" y=".5" width="39" height="39" rx="11" fill="#242329" stroke="#45434f" />
<path d="M10 29V11h4l6 9 6-9h4v18h-4V18l-6 8-6-8v11h-4Z" fill="#dedaff" />
</svg>
);
}
+8 -8
View File
@@ -1,12 +1,12 @@
import type { ReactNode } from 'react'
import type { ReactNode } from "react";
type PageHeadingProps = {
title: string
description?: string
eyebrow?: string
leading?: ReactNode
actions?: ReactNode
}
title: string;
description?: string;
eyebrow?: string;
leading?: ReactNode;
actions?: ReactNode;
};
/** A flat, shared page title. Panels belong to the content below it. */
export default function PageHeading({ title, description, eyebrow, leading, actions }: PageHeadingProps) {
@@ -22,5 +22,5 @@ export default function PageHeading({ title, description, eyebrow, leading, acti
</div>
{actions && <div className="page-heading-actions">{actions}</div>}
</header>
)
);
}
+33
View File
@@ -0,0 +1,33 @@
export type RequestStage = "all" | "pending" | "in_progress" | "working" | "ready";
const REQUEST_STAGE_OPTIONS: ReadonlyArray<{ value: RequestStage; label: string }> = [
{ value: "all", label: "All" },
{ value: "pending", label: "Waiting" },
{ value: "in_progress", label: "In progress" },
{ value: "working", label: "Working" },
{ value: "ready", label: "Ready" },
];
export default function RequestStageFilter({
value,
onChange,
}: {
value: RequestStage;
onChange: (stage: RequestStage) => void;
}) {
return (
<nav className="request-filter-chips" aria-label="Filter requests by stage">
{REQUEST_STAGE_OPTIONS.map((option) => (
<button
type="button"
key={option.value}
className={value === option.value ? "is-active" : undefined}
onClick={() => onChange(option.value)}
>
{option.value === "working" ? <i aria-hidden="true" /> : null}
{option.label}
</button>
))}
</nav>
);
}
+29 -15
View File
@@ -1,19 +1,33 @@
'use client'
"use client";
import './resolution-choice.css'
import "./resolution-choice.css";
export default function ResolutionChoice({ title, busy, onAnswer }: {
title: string; busy: boolean; onAnswer: (resolved: boolean) => void
export default function ResolutionChoice({
title,
busy,
onAnswer,
}: {
title: string;
busy: boolean;
onAnswer: (resolved: boolean) => void;
}) {
return <section className="resolution-choice" aria-labelledby="resolution-question" aria-busy={busy}>
<span className="section-kicker">Your answer is needed</span>
<h2 id="resolution-question">Is it fixed?</h2>
<p>{title}</p>
<p>Try the affected content in Grizzlyflix, then choose:</p>
<div className="resolution-choice-buttons">
<button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}><strong>YES</strong><span>It works close this issue</span></button>
<button id="no" type="button" className="resolution-no" disabled={busy} onClick={() => onAnswer(false)}><strong>NO</strong><span>Still broken keep it open</span></button>
</div>
{busy && <p role="status">Saving your answer</p>}
</section>
return (
<section className="resolution-choice" aria-labelledby="resolution-question" aria-busy={busy}>
<span className="section-kicker">Your answer is needed</span>
<h2 id="resolution-question">Is it fixed?</h2>
<p>{title}</p>
<p>Try the affected content in Grizzlyflix, then choose:</p>
<div className="resolution-choice-buttons">
<button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}>
<strong>YES</strong>
<span>It works close this issue</span>
</button>
<button id="no" type="button" className="resolution-no" disabled={busy} onClick={() => onAnswer(false)}>
<strong>NO</strong>
<span>Still broken keep it open</span>
</button>
</div>
{busy && <p role="status">Saving your answer</p>}
</section>
);
}
+31 -14
View File
@@ -1,18 +1,35 @@
'use client'
"use client";
import { usePathname, useRouter } from 'next/navigation'
import { CONFIG_GROUPS } from '../admin/configNavigation'
import { usePathname, useRouter } from "next/navigation";
import { CONFIG_GROUPS } from "../admin/configNavigation";
export default function SettingsNavigation() {
const pathname = usePathname()
const router = useRouter()
const current = CONFIG_GROUPS.flatMap((group) => group.items).find((item) => item.href === pathname)
if (pathname === '/admin') return null
return <nav className="settings-top-navigation" aria-label="Settings navigation">
<a href="/admin"> All settings</a>
<label><span>Jump to</span><select aria-label="Settings section" value={current?.href ?? '/admin'} onChange={(event) => router.push(event.target.value)}>
<option value="/admin">Settings overview</option>
{CONFIG_GROUPS.map((group) => <optgroup label={group.title} key={group.title}>{group.items.map((item) => <option key={item.href} value={item.href}>{item.label}</option>)}</optgroup>)}
</select></label>
</nav>
const pathname = usePathname();
const router = useRouter();
const current = CONFIG_GROUPS.flatMap((group) => group.items).find((item) => item.href === pathname);
if (pathname === "/admin") return null;
return (
<nav className="settings-top-navigation" aria-label="Settings navigation">
<a href="/admin"> All settings</a>
<label>
<span>Jump to</span>
<select
aria-label="Settings section"
value={current?.href ?? "/admin"}
onChange={(event) => router.push(event.target.value)}
>
<option value="/admin">Settings overview</option>
{CONFIG_GROUPS.map((group) => (
<optgroup label={group.title} key={group.title}>
{group.items.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</optgroup>
))}
</select>
</label>
</nav>
);
}
+42 -40
View File
@@ -1,68 +1,70 @@
'use client'
"use client";
import { useEffect, useState, type CSSProperties } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { useEffect, useState, type CSSProperties } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type BannerInfo = {
enabled: boolean
message: string
tone?: string
backgroundColor?: string | null
borderColor?: string | null
}
enabled: boolean;
message: string;
tone?: string;
backgroundColor?: string | null;
borderColor?: string | null;
};
type SiteInfo = {
buildNumber?: string
banner?: BannerInfo
}
buildNumber?: string;
banner?: BannerInfo;
};
const buildRequest = () => {
const token = getToken()
const baseUrl = getApiBase()
const url = token ? `${baseUrl}/site/info` : `${baseUrl}/site/public`
const fetcher = token ? authFetch : fetch
return { token, url, fetcher }
}
const token = getToken();
const baseUrl = getApiBase();
const url = token ? `${baseUrl}/site/info` : `${baseUrl}/site/public`;
const fetcher = token ? authFetch : fetch;
return { token, url, fetcher };
};
export default function SiteStatus() {
const [info, setInfo] = useState<SiteInfo | null>(null)
const [info, setInfo] = useState<SiteInfo | null>(null);
useEffect(() => {
let active = true
let active = true;
const load = async () => {
try {
const { token, url, fetcher } = buildRequest()
const response = await fetcher(url)
const { token, url, fetcher } = buildRequest();
const response = await fetcher(url);
if (!response.ok) {
if (response.status === 401 && token) {
clearToken()
clearToken();
}
return
return;
}
const data = await response.json()
if (!active) return
setInfo(data)
const data = await response.json();
if (!active) return;
setInfo(data);
} catch (err) {
console.error(err)
console.error(err);
}
}
void load()
};
void load();
return () => {
active = false
}
}, [])
active = false;
};
}, []);
const banner = info?.banner
const tone = banner?.tone || 'info'
const banner = info?.banner;
const tone = banner?.tone || "info";
const bannerStyle = {
'--site-banner-background-color': banner?.backgroundColor || undefined,
'--site-banner-border-color': banner?.borderColor || undefined,
} as CSSProperties
"--site-banner-background-color": banner?.backgroundColor || undefined,
"--site-banner-border-color": banner?.borderColor || undefined,
} as CSSProperties;
return (
<>
{banner?.enabled && banner.message ? (
<div className={`site-banner site-banner--${tone}`} style={bannerStyle}>{banner.message}</div>
<div className={`site-banner site-banner--${tone}`} style={bannerStyle}>
{banner.message}
</div>
) : null}
</>
)
);
}
+5 -5
View File
@@ -1,11 +1,11 @@
'use client'
"use client";
import { setUserViewPreview, useUserViewPreview } from '../lib/viewMode'
import { setUserViewPreview, useUserViewPreview } from "../lib/viewMode";
export default function UserViewBanner() {
const enabled = useUserViewPreview()
const enabled = useUserViewPreview();
if (!enabled) return null
if (!enabled) return null;
return (
<div className="user-view-banner" role="status">
@@ -17,5 +17,5 @@ export default function UserViewBanner() {
Exit user view
</button>
</div>
)
);
}
+119 -43
View File
@@ -1,60 +1,136 @@
'use client'
"use client";
import { usePathname } from 'next/navigation'
import { getToken } from '../lib/auth'
import { canAccess, featureForPath } from '../lib/features'
import { useFeatureUser } from './FeatureGate'
import { usePathname } from "next/navigation";
import { getToken } from "../lib/auth";
import { canAccess, featureForPath } from "../lib/features";
import { useFeatureUser } from "./FeatureGate";
type NavigationItem = {
href: string
label: string
shortLabel: string
icon: 'dashboard' | 'media' | 'issues' | 'invites' | 'settings' | 'stats'
adminOnly?: boolean
match: (path: string) => boolean
}
href: string;
label: string;
shortLabel: string;
icon: "dashboard" | "media" | "issues" | "invites" | "settings" | "stats";
adminOnly?: boolean;
match: (path: string) => boolean;
};
const NAVIGATION: NavigationItem[] = [
{ href: '/new-requests', label: 'New Request', shortLabel: 'New', icon: 'media', match: (path) => path === '/new-requests' },
{ href: '/', label: 'My Requests', shortLabel: 'Requests', icon: 'dashboard', match: (path) => path === '/' || path.startsWith('/requests/') },
{ href: '/insights', label: 'My Stats', shortLabel: 'Stats', icon: 'stats', match: (path) => path === '/insights' || path.startsWith('/insights/') },
{ href: '/portal/issues', label: 'Issues', shortLabel: 'Issues', icon: 'issues', match: (path) => path.startsWith('/portal/issues') },
{ href: '/profile/invites', label: 'Invites', shortLabel: 'Invites', icon: 'invites', match: (path) => path.startsWith('/profile/invites') },
{ href: '/admin', label: 'Configuration', shortLabel: 'Config', icon: 'settings', adminOnly: true, match: (path) => path.startsWith('/admin') || path.startsWith('/users') },
]
{
href: "/new-requests",
label: "New Request",
shortLabel: "New",
icon: "media",
match: (path) => path === "/new-requests",
},
{
href: "/",
label: "My Requests",
shortLabel: "Requests",
icon: "dashboard",
match: (path) => path === "/" || path.startsWith("/requests/"),
},
{
href: "/insights",
label: "My Stats",
shortLabel: "Stats",
icon: "stats",
match: (path) => path === "/insights" || path.startsWith("/insights/"),
},
{
href: "/portal/issues",
label: "Issues",
shortLabel: "Issues",
icon: "issues",
match: (path) => path.startsWith("/portal/issues"),
},
{
href: "/profile/invites",
label: "Invites",
shortLabel: "Invites",
icon: "invites",
match: (path) => path.startsWith("/profile/invites"),
},
{
href: "/admin",
label: "Configuration",
shortLabel: "Config",
icon: "settings",
adminOnly: true,
match: (path) => path.startsWith("/admin") || path.startsWith("/users"),
},
];
const HIDDEN_ROUTES = ['/login', '/signup', '/forgot-password', '/reset-password', '/how-it-works']
const HIDDEN_ROUTES = ["/login", "/signup", "/forgot-password", "/reset-password", "/how-it-works"];
function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
const paths: Record<NavigationItem['icon'], React.ReactNode> = {
stats: <><path d="M4 20h16M6 16v-5m6 5V4m6 12V8" /></>,
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
media: <><rect x="3" y="5" width="18" height="15" rx="2" /><path d="m8 3 2 4m4-4 2 4M3 10h18" /><path d="m10 13 5 3-5 3z" /></>,
issues: <><path d="M12 3 2.8 19h18.4L12 3Z" /><path d="M12 9v4m0 3h.01" /></>,
invites: <><circle cx="9" cy="8" r="3" /><path d="M3.5 20v-2.2A4.8 4.8 0 0 1 8.3 13h1.4a4.8 4.8 0 0 1 3.8 1.9M17 8v6m-3-3h6" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" /></>,
}
return <svg viewBox="0 0 24 24" aria-hidden="true">{paths[name]}</svg>
function NavigationIcon({ name }: { name: NavigationItem["icon"] }) {
const paths: Record<NavigationItem["icon"], React.ReactNode> = {
stats: (
<>
<path d="M4 20h16M6 16v-5m6 5V4m6 12V8" />
</>
),
dashboard: (
<>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</>
),
media: (
<>
<rect x="3" y="5" width="18" height="15" rx="2" />
<path d="m8 3 2 4m4-4 2 4M3 10h18" />
<path d="m10 13 5 3-5 3z" />
</>
),
issues: (
<>
<path d="M12 3 2.8 19h18.4L12 3Z" />
<path d="M12 9v4m0 3h.01" />
</>
),
invites: (
<>
<circle cx="9" cy="8" r="3" />
<path d="M3.5 20v-2.2A4.8 4.8 0 0 1 8.3 13h1.4a4.8 4.8 0 0 1 3.8 1.9M17 8v6m-3-3h6" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
};
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
{paths[name]}
</svg>
);
}
export default function WorkspaceNavigation() {
const pathname = usePathname()
const { user, ready } = useFeatureUser()
const role = user?.role
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const role = user?.role;
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
return null
return null;
}
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && canAccess(user, featureForPath(item.href)))
const items = NAVIGATION.filter(
(item) => (!item.adminOnly || role === "admin") && canAccess(user, featureForPath(item.href)),
);
return (
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
{items.map((item) => (
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
<NavigationIcon name={item.icon} /><span>{item.shortLabel}</span>
</a>
))}
</nav>
)
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
{items.map((item) => (
<a key={item.href} href={item.href} className={item.match(pathname) ? "is-active" : undefined}>
<NavigationIcon name={item.icon} />
<span>{item.shortLabel}</span>
</a>
))}
</nav>
);
}