dragonflight/services/web-ui/public/shell.jsx
opencode 002e5acb82 auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
Scope (locked in via planning Q&A):
  - Identity: local accounts only (PG users table) + existing bearer
    tokens for headless callers.
  - Transport: httpOnly cookie session for browser, Bearer for API.
  - RBAC: admin / editor / viewer roles, plus an orthogonal
    is_client flag for external (agency, talent, customer) accounts.
  - Bootstrap: ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD env
    seed the first admin on a clean install. Set ADMIN_BOOTSTRAP_RESET
    to force-reset the named user (break-glass).
  - Rate limit: in-memory, 10 fails per 15min per (IP, username).
  - Password policy: \u22658 chars, mixed case, digit, symbol; small
    blocklist of common passwords; cannot equal username.
  - Self-service: change own display name + password. Everything
    else (role, is_client, other-user mgmt) is admin only.
  - Audit log: append-only table, indexed by actor + event_type +
    created_at, populated by every auth/admin event.

Files added:
  - services/mam-api/src/db/migrations/022-auth-rework.sql
        users.is_client + last_login_at + failed_attempts; audit_log
        table with FK to users (ON DELETE SET NULL).
  - services/mam-api/src/middleware/audit.js
        Fire-and-forget audit() helper. Caller never awaits, failure
        logs but never throws — auditing cannot break the request
        that triggered it.
  - services/mam-api/src/middleware/passwordPolicy.js
        Shared checkPassword(pw, { username }) used by setup, user
        create/update, and self-service password change.
  - services/mam-api/src/tasks/bootstrapAdmin.js
        Runs after migrations. No-ops unless ADMIN_BOOTSTRAP_USER +
        ADMIN_BOOTSTRAP_PASSWORD are set AND (users table empty OR
        ADMIN_BOOTSTRAP_RESET=true).
  - services/mam-api/src/routes/audit.js
        Admin-only GET /audit (paginated, filter by event_type /
        actor / target / date) and GET /audit/event-types.
  - services/web-ui/public/modal-account-settings.jsx
        Profile + Password tabs. Triggered by sidebar user button.

Files rewritten:
  - services/mam-api/src/routes/auth.js
        - POST /login: regenerate(), no manual save(); audit success/
          fail/lockout; updates last_login_at + failed_attempts.
        - POST /logout: destroys session, audits logout.
        - GET /me: returns is_client + last_login_at. Synthetic admin
          when AUTH_ENABLED=false.
        - GET /setup-status: drives login.html UI state.
        - POST /setup: blocked once any user exists; password policy.
        - POST /password: self-service. Requires current pw, runs
          policy, audits, invalidates other sessions implicitly via
          users.js if changed by admin.
        - PATCH /me: self-service display_name update.
  - services/mam-api/src/routes/users.js
        - is_client field in create/update/list/get.
        - Guardrails: cannot delete or demote last admin, cannot
          delete self, admins cannot be flagged is_client.
        - Password change invalidates all sessions for that user
          (DELETE FROM sessions WHERE sess->>'userId' = id).
        - Audit on every mutation.
        - Password policy enforced.
  - services/mam-api/src/middleware/auth.js
        - requireAuth now exposes req.user.is_client.
        - New requireRole(["admin","editor"], { rejectClients: true })
          helper. Applied to cluster, sdk, capture routes (infra).
        - Synthetic user when AUTH_ENABLED=false has is_client=false.
  - services/mam-api/src/index.js
        - Loads bootstrap admin after migrations.
        - Wires /api/v1/audit.
        - Cleans up an earlier comment block.
  - services/web-ui/public/login.html
        - Password hint added next to setup-mode password field.
  - services/web-ui/public/shell.jsx
        - Sidebar user footer is a button that opens AccountSettings.
        - CLIENT badge next to role when is_client=true.
        - Nav filters: clients lose ingest tree + jobs + editor;
          viewers lose ingest + editor; only admins see the Admin
          section. Power button hidden when synthetic user.
  - services/web-ui/public/screens-admin.jsx
        - Users table: new Client column with inline toggle.
        - InviteUserModal: Client checkbox + password hint, gated
          off when role=admin.
        - Last login column replaces Created in primary view.
        - CSV export includes client + last_login.
  - services/web-ui/public/data.jsx
        - ZAMPP_DATA.ME carries is_client + display_name.
  - services/web-ui/public/index.html
        - Loads dist/modal-account-settings.js.
  - services/web-ui/public/styles-rest.css
        - .user-row grid widened to 6 columns.
  - docker-compose.yml
        - Plumbs SESSION_COOKIE_SECURE + ADMIN_BOOTSTRAP_* env vars.

Deploy:
  cd /opt/wild-dragon
  git pull origin main
  # In .env:
  #   AUTH_ENABLED=true
  #   SESSION_SECRET=<openssl rand -hex 48>
  #   ADMIN_BOOTSTRAP_USER=admin
  #   ADMIN_BOOTSTRAP_PASSWORD=<strong>
  docker compose build mam-api web-ui
  docker compose up -d --force-recreate --no-deps mam-api web-ui
2026-05-27 03:21:16 +00:00

488 lines
18 KiB
JavaScript

// shell.jsx - app shell: sidebar nav, topbar, route container
const NAV_TREE = [
{ id: "home", label: "Home", icon: "home" },
{ id: "dashboard", label: "Dashboard", icon: "layout" },
{ id: "library", label: "Library", icon: "library" },
{ id: "projects", label: "Projects", icon: "folder" },
{
id: "ingest", label: "Ingest", icon: "upload", group: true,
children: [
{ id: "upload", label: "Upload", icon: "upload" },
{ id: "youtube", label: "YouTube", icon: "download" },
{ id: "recorders", label: "Recorders", icon: "record" },
{ id: "schedule", label: "Schedule", icon: "jobs" },
{ id: "capture", label: "Capture", icon: "capture" },
{ id: "monitors", label: "Monitors", icon: "monitor" },
],
},
{ id: "jobs", label: "Jobs", icon: "jobs" },
{ id: "editor", label: "Editor", icon: "editor" },
];
const ADMIN_TREE = [
{ id: "users", label: "Users", icon: "users" },
{ id: "tokens", label: "Tokens", icon: "token" },
{ id: "containers", label: "Containers", icon: "container" },
{ id: "cluster", label: "Cluster", icon: "cluster" },
{ id: "settings", label: "Settings", icon: "settings" },
];
const NAV_FLAT = (() => {
const out = [];
const visit = (arr) => arr.forEach(n => {
if (n.group && n.children) { visit(n.children); return; }
out.push({ id: n.id, label: n.label, icon: n.icon });
});
visit(NAV_TREE); visit(ADMIN_TREE);
return out;
})();
function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) {
const isGroup = item.group && item.children;
const isOpen = isGroup && openGroups.has(item.id);
const isActive = active === item.id || (isGroup && item.children.some(c => c.id === active));
return (
<>
<div
className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
data-tip={item.label}
onClick={() => {
if (isGroup) toggleGroup(item.id);
else onSelect(item.id);
}}
>
<Icon name={item.icon} size={15} className="nav-icon" />
<span>{item.label}</span>
{item.badge && (
<span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}</span>
)}
{isGroup && <Icon name="chevron" size={12} className="nav-caret" />}
</div>
{isGroup && isOpen && (
<div className="nav-children">
{item.children.map(c => (
<div
key={c.id}
className={`nav-item ${active === c.id ? "active" : ""}`}
onClick={() => onSelect(c.id)}
>
<Icon name={c.icon} size={14} className="nav-icon" />
<span>{c.label}</span>
{c.badge && <span className={`nav-badge ${c.badge.kind}`}>{c.badge.text}</span>}
</div>
))}
</div>
)}
</>
);
}
function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
const [jobsBadge, setJobsBadge] = React.useState(null);
// Live jobs count (#130) — poll /jobs/count for active jobs and render the
// result as the sidebar badge. Falls back to hidden on error.
React.useEffect(() => {
let cancelled = false;
const tick = () => {
window.ZAMPP_API.fetch('/jobs?status=active&limit=200')
.then(d => {
if (cancelled) return;
const list = Array.isArray(d) ? d : (d?.jobs || d?.items || []);
const n = Array.isArray(list) ? list.length : 0;
setJobsBadge(n > 0 ? { kind: n > 5 ? 'warning' : 'neutral', text: n > 99 ? '99+' : String(n) } : null);
})
.catch(() => setJobsBadge(null));
};
tick();
const id = setInterval(tick, 10000);
return () => { cancelled = true; clearInterval(id); };
}, []);
// Apply the live jobs badge to the Jobs nav item, and gate items the
// current user shouldn't see. Clients lose the entire ingest tree, jobs,
// and the editor. Viewers keep Library + Projects.
const isClient = !!me?.is_client;
const role = me?.role || 'viewer';
const isAdmin = role === 'admin';
const isEditor = role === 'editor' || isAdmin;
const navTree = React.useMemo(() => {
const filterChildren = (children) => children.filter(c => {
if (isClient && ['recorders', 'capture', 'monitors', 'schedule'].includes(c.id)) return false;
return true;
});
return NAV_TREE
.map(n => n.id === 'jobs' && jobsBadge ? { ...n, badge: jobsBadge } : n)
.filter(n => {
if (isClient && ['ingest', 'jobs', 'editor'].includes(n.id)) return false;
if (!isEditor && ['ingest', 'editor'].includes(n.id)) return false;
return true;
})
.map(n => n.children ? { ...n, children: filterChildren(n.children) } : n)
.filter(n => !n.children || n.children.length > 0);
}, [jobsBadge, isClient, isEditor]);
// Admin section visible only to admins.
const adminTree = React.useMemo(() => isAdmin ? ADMIN_TREE : [], [isAdmin]);
const toggleGroup = (id) => {
setOpenGroups(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
React.useEffect(() => {
const ingestChildren = ["upload", "youtube", "recorders", "schedule", "capture", "monitors"];
if (ingestChildren.includes(active)) {
setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
}
}, [active]);
return (
<aside className="sidebar">
<div className="sidebar-header">
<div
className="brand-link"
onClick={() => onNavigate('home')}
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}
title="Home"
>
<img className="brand-logo" src="img/dragon-logo.png" alt="Dragonflight" draggable="false" />
<div className="brand-name">Dragonflight</div>
<div className="brand-sub">v1.2.0</div>
</div>
<button
className="icon-btn sidebar-toggle"
onClick={onToggle}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-expanded={!collapsed}
>
<Icon name="chevron" size={14} style={{ transform: collapsed ? 'rotate(0deg)' : 'rotate(180deg)', transition: 'transform 120ms' }} />
</button>
</div>
<div className="sidebar-scroll">
{navTree.map(item => (
<NavItem
key={item.id}
item={item}
active={active}
onSelect={onNavigate}
openGroups={openGroups}
toggleGroup={toggleGroup}
/>
))}
{adminTree.length > 0 && (
<>
<div className="nav-section-label">Admin</div>
{adminTree.map(item => (
<NavItem
key={item.id}
item={item}
active={active}
onSelect={onNavigate}
openGroups={openGroups}
toggleGroup={toggleGroup}
/>
))}
</>
)}
</div>
<div className="sidebar-footer">
<button
className="sidebar-userbtn"
onClick={() => { if (!me?.synthetic) window.dispatchEvent(new CustomEvent('df:open-account-settings')); }}
disabled={!!me?.synthetic}
title={me?.synthetic ? 'AUTH_ENABLED=false — account settings unavailable' : 'Account settings'}
aria-label="Account settings"
style={{
display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0,
padding: 0, background: 'none', border: 0, color: 'inherit',
cursor: me?.synthetic ? 'default' : 'pointer', textAlign: 'left',
}}
>
<div className="avatar">{me?.initials || '—'}</div>
<div className="user-meta">
<div className="user-name">{me?.name || 'Not signed in'}</div>
<div className="user-role">
{me?.role || '—'}
{me?.is_client ? <span className="badge neutral" style={{ marginLeft: 6, fontSize: 9 }}>CLIENT</span> : null}
{me?.synthetic ? ' · auth off' : ''}
</div>
</div>
</button>
{me?.synthetic ? null : (
<button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out"
onClick={async () => {
try { await window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' }); } catch (_) {}
window.location.replace('/login.html');
}}>
<Icon name="power" />
</button>
)}
</div>
</aside>
);
}
function assetSearchIcon(a) {
const t = (a.type || a.media_type || '').toLowerCase();
if (t.includes('audio')) return 'audio';
if (t.includes('image')) return 'image';
return 'film';
}
function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
const [q, setQ] = React.useState('');
const [open, setOpen] = React.useState(false);
const [sel, setSel] = React.useState(0);
const inputRef = React.useRef(null);
const listRef = React.useRef(null);
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
React.useEffect(() => {
const handler = (e) => {
const key = e.key && e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && key === 'k') {
e.preventDefault();
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
setOpen(true);
}
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const results = React.useMemo(() => {
const term = q.trim().toLowerCase();
if (!term) return [];
const D = window.ZAMPP_DATA || {};
const out = [];
(D.ASSETS || []).forEach(a => {
const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
if (hay.includes(term)) {
const sub = [a.project, a.res, a.duration].filter(x => x && x !== '—').join(' · ');
out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
}
});
(D.PROJECTS || []).forEach(p => {
if ((p.name || '').toLowerCase().includes(term)) {
out.push({ kind: 'project', icon: 'folder', label: p.name, sub: (p.assets || 0) + ' assets', item: p, color: p.color });
}
});
(D.RECORDERS || []).forEach(r => {
const hay = ((r.name || '') + ' ' + (r.url || '') + ' ' + (r.source || '')).toLowerCase();
if (hay.includes(term)) {
out.push({ kind: 'recorder', icon: 'record', label: r.name || r.url || 'Recorder', sub: [r.status, r.source].filter(Boolean).join(' · '), item: r });
}
});
(D.JOBS || []).forEach(j => {
const hay = ((j.asset || '') + ' ' + (j.kind || '') + ' ' + (j.status || '')).toLowerCase();
if (hay.includes(term)) {
out.push({ kind: 'job', icon: 'jobs', label: j.asset || j.kind || 'Job', sub: [j.kind, j.status].filter(Boolean).join(' · '), item: j });
}
});
(D.USERS || []).forEach(u => {
const hay = ((u.name || '') + ' ' + (u.email || '') + ' ' + (u.username || '')).toLowerCase();
if (hay.includes(term)) {
out.push({ kind: 'user', icon: 'users', label: u.name, sub: u.email || u.role, item: u });
}
});
NAV_FLAT.forEach(n => {
if (n.label.toLowerCase().includes(term)) {
out.push({ kind: 'nav', icon: n.icon, label: n.label, sub: 'Go to ' + n.label, target: n.id });
}
});
return out.slice(0, 40);
}, [q, open]);
React.useEffect(() => { setSel(0); }, [q]);
React.useEffect(() => {
if (!listRef.current) return;
const el = listRef.current.querySelector('.search-result.active');
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
}, [sel]);
const handleSelect = (r) => {
if (!r) return;
setOpen(false);
setQ('');
if (inputRef.current) inputRef.current.blur();
if (r.kind === 'asset' && onOpenAsset) onOpenAsset(r.item);
else if (r.kind === 'project' && onOpenProject) onOpenProject(r.item);
else if (r.kind === 'recorder') onNavigate && onNavigate('recorders');
else if (r.kind === 'job') onNavigate && onNavigate('jobs');
else if (r.kind === 'user') onNavigate && onNavigate('users');
else if (r.kind === 'nav') onNavigate && onNavigate(r.target);
};
const onKeyDown = (e) => {
if (e.key === 'Escape') {
setOpen(false); setQ('');
if (inputRef.current) inputRef.current.blur();
return;
}
if (!open || results.length === 0) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, results.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); }
else if (e.key === 'Enter') { e.preventDefault(); handleSelect(results[sel]); }
};
const showDropdown = open && q.trim().length > 0;
const listboxId = 'global-search-listbox';
return (
<div className="search-wrap" role="search">
<div className={`search ${showDropdown ? 'is-open' : ''}`}>
<Icon name="search" className="search-icon" />
<input
ref={inputRef}
value={q}
onChange={e => { setQ(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
onKeyDown={onKeyDown}
placeholder="Search assets, projects, recorders…"
type="search"
role="combobox"
aria-label="Search assets, projects, recorders, jobs, and users"
aria-expanded={showDropdown}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={showDropdown && results[sel] ? `${listboxId}-opt-${sel}` : undefined}
/>
<span className="kbd" aria-hidden="true">{isMac ? '⌘K' : 'Ctrl K'}</span>
</div>
{showDropdown && (
<div
ref={listRef}
id={listboxId}
role="listbox"
className="search-results"
onMouseDown={(e) => e.preventDefault()}
>
{results.length === 0 ? (
<div className="search-empty">No results for "{q}"</div>
) : results.map((r, i) => (
<div
key={r.kind + '-' + i + '-' + (r.item ? (r.item.id || r.item.name) : (r.target || r.label))}
id={`${listboxId}-opt-${i}`}
role="option"
aria-selected={i === sel}
className={`search-result ${i === sel ? 'active' : ''}`}
onClick={() => handleSelect(r)}
onMouseEnter={() => setSel(i)}
>
<span
className="search-result-icon"
style={r.color ? { color: r.color } : null}
>
<Icon name={r.icon} size={14} />
</span>
<div className="search-result-text">
<div className="search-result-label">{r.label}</div>
{r.sub && <div className="search-result-sub">{r.sub}</div>}
</div>
<span className={`search-result-kind kind-${r.kind}`}>{r.kind}</span>
</div>
))}
</div>
)}
</div>
);
}
function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject, onToggleSidebar }) {
// Light cluster ping — the badge in the topbar should reflect reality,
// not just look reassuring. /metrics/home returns cluster online/total.
const [clusterHealthy, setClusterHealthy] = React.useState(true);
React.useEffect(() => {
let cancelled = false;
const ping = () => {
window.ZAMPP_API.fetch('/metrics/home?hours=1')
.then(d => {
if (cancelled) return;
const c = d?.cards?.cluster;
setClusterHealthy(!c || c.online >= c.total);
})
.catch(() => {});
};
ping();
const t = setInterval(ping, 30_000);
return () => { cancelled = true; clearInterval(t); };
}, []);
return (
<header className="topbar">
{onToggleSidebar && (
<button
className="icon-btn topbar-menu"
onClick={onToggleSidebar}
aria-label="Toggle sidebar"
title="Toggle sidebar"
>
<Icon name="list" size={16} />
</button>
)}
<div className="crumb">
{crumbs.map((c, i) => (
<React.Fragment key={i}>
{i > 0 && <Icon name="chevron" size={12} className="sep" />}
<span
className={i === crumbs.length - 1 ? "current" : ""}
style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
onClick={() => c.to && onNavigate && onNavigate(c.to)}
>
{c.label}
</span>
</React.Fragment>
))}
</div>
<div className="spacer" />
<GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />
<div className="status-pip" title={clusterHealthy ? 'All nodes online' : 'One or more nodes offline'}>
<span className="dot" style={{ background: clusterHealthy ? 'var(--success)' : 'var(--warning)' }} />
<span>{clusterHealthy ? 'cluster healthy' : 'cluster degraded'}</span>
</div>
{right}
</header>
);
}
// General-purpose read-only form field used by recorder modal and other forms.
// Renders as a labeled select (when select=true) or text input.
function Field({ label, value, select, children }) {
return (
<div className="field">
<label className="field-label">{label}</label>
{children || (select
? (
<select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
<option value={value}>{value}</option>
</select>
) : (
<input className="field-input" defaultValue={value} readOnly />
)
)}
</div>
);
}
window.Sidebar = Sidebar;
window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE;
window.NAV_FLAT = NAV_FLAT;
window.Field = Field;
window.GlobalSearch = GlobalSearch;