Failed to load active analytics.
;
+ if (!ready) return
+
+
+
+
+
+
+
+
+
+
+
+
+ ['#16a34a', '#22c55e', '#f59e0b', '#ea580c', '#dc2626'][i] ?? 'var(--primary)'} />
+
+
+
+
+ `${n}d`} labelWidth={150} />
+
+
+
+
+ {jiraDur.length > 0 && (
+ ({ key: d.status, label: `${d.status} · ${d.count}`, count: d.avgDays }))}
+ format={n => (n < 1 ? `${Math.round(n * 24)}h` : `${n}d`)}
+ colorFor={(_, i) => `hsl(${(i * 37) % 360} 62% 55%)`}
+ labelWidth={200}
+ />
+ )}
+
+
+ );
+}
diff --git a/client/src/pages/Admin/Admin.module.scss b/client/src/pages/Admin/Admin.module.scss
new file mode 100644
index 0000000..c602d9b
--- /dev/null
+++ b/client/src/pages/Admin/Admin.module.scss
@@ -0,0 +1,64 @@
+@use '../../styles/variables' as *;
+
+.page { max-width: none; display: flex; flex-direction: column; gap: $s-4; }
+
+.tabs {
+ display: inline-flex;
+ gap: 2px;
+ border-bottom: 1px solid var(--border);
+ button {
+ height: 36px; padding: 0 16px; border: none; background: transparent;
+ color: var(--text-muted); font-size: 13px; font-weight: 600; cursor: pointer;
+ border-bottom: 2px solid transparent; margin-bottom: -1px;
+ &.on { color: var(--primary); border-bottom-color: var(--primary); }
+ &:hover:not(.on) { color: var(--text); }
+ }
+}
+
+.grid { display: grid; grid-template-columns: 340px 1fr; gap: $s-4; align-items: start; @media (max-width: 900px) { grid-template-columns: 1fr; } }
+
+.card {
+ background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg;
+ padding: $s-5; box-shadow: var(--shadow-sm);
+}
+.cardTitle { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: $s-4; }
+.hint { font-size: 12px; color: var(--text-muted); margin-bottom: $s-3; margin-top: -8px; }
+
+.label { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 12px 0 5px; }
+.input {
+ width: 100%; height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: $radius;
+ font-size: 13px; color: var(--text); background: var(--surface); outline: none;
+ &:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
+}
+.err { margin-top: 10px; padding: 8px 10px; border-radius: $radius; background: var(--danger-bg); color: var(--danger); font-size: 12px; }
+.btn {
+ margin-top: 14px; width: 100%; height: 38px; border: none; border-radius: $radius;
+ background: var(--primary); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
+ &:hover:not(:disabled) { background: var(--primary-h); }
+ &:disabled { opacity: 0.6; cursor: default; }
+}
+
+.minted { margin-top: 14px; padding: 12px; border: 1px solid var(--success); border-radius: $radius; background: var(--success-bg); }
+.mintedLabel { font-size: 11px; font-weight: 700; color: var(--success); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 6px; }
+.mintedToken { display: block; font-family: var(--mono); font-size: 11px; word-break: break-all; color: var(--text); cursor: pointer; background: var(--surface); padding: 8px; border-radius: $radius; }
+.copy { margin-top: 8px; height: 28px; padding: 0 12px; border: 1px solid var(--border); border-radius: $radius; background: var(--surface); font-size: 12px; font-weight: 600; cursor: pointer; }
+
+.table {
+ width: 100%; border-collapse: collapse; font-size: 13px;
+ th { text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); padding: 8px 10px; border-bottom: 1px solid var(--border); }
+ td { padding: 8px 10px; border-bottom: 1px solid var(--border); }
+ tr:last-child td { border-bottom: none; }
+}
+.muted { color: var(--text-muted); }
+.mono { font-family: var(--mono); font-size: 12px; color: var(--text-muted); }
+.right { text-align: right; }
+.roleBadge {
+ font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px;
+ background: var(--surface-alt); color: var(--text-muted);
+ &[data-role='admin'] { background: var(--danger-bg); color: var(--danger); }
+ &[data-role='lead'] { background: var(--violet-bg, rgba(139,92,246,.1)); color: #7c3aed; }
+ &[data-role='pm'] { background: var(--primary-bg); color: var(--primary); }
+}
+.danger { height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: $radius; background: var(--surface); color: var(--danger); font-size: 12px; font-weight: 600; cursor: pointer; &:hover { background: var(--danger-bg); } }
+.revoked { font-size: 12px; color: var(--text-dim); font-style: italic; }
+.revokedRow { opacity: 0.5; }
diff --git a/client/src/pages/Admin/index.tsx b/client/src/pages/Admin/index.tsx
new file mode 100644
index 0000000..2356e2e
--- /dev/null
+++ b/client/src/pages/Admin/index.tsx
@@ -0,0 +1,154 @@
+import { useEffect, useState, useCallback } from 'react';
+import PageHeader from '../../components/PageHeader';
+import { listUsers, createUser, deleteUser, listTokens, createToken, revokeToken, type AppUser, type TokenInfo } from '../../services/admin.service';
+import { ROLE_LABELS, roleAtLeast, type Role, type CurrentUser } from '../../services/auth.service';
+import styles from './Admin.module.scss';
+
+const fmt = (v: string | null) => (v ? new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '—');
+
+export default function Admin({ user }: { user: CurrentUser }) {
+ const isAdmin = user.role === 'admin';
+ const [tab, setTab] = useState<'users' | 'tokens'>('users');
+ useEffect(() => { if (!isAdmin) setTab('users'); }, [isAdmin]);
+
+ return (
+