dragonflight/services/web-ui/public/app.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

168 lines
7.6 KiB
JavaScript

// app.jsx — main shell
const ACCENT = '#5B7CFA';
function App() {
const [route, setRoute] = React.useState('home');
const [openAsset, setOpenAsset] = React.useState(null);
const [openProject, setOpenProject] = React.useState(null);
const [showNewRecorder, setShowNewRecorder] = React.useState(false);
const [dataReady, setDataReady] = React.useState(false);
const [loadError, setLoadError] = React.useState(null);
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(() => {
try {
const stored = localStorage.getItem('df.sidebar.collapsed');
if (stored != null) return stored === '1';
// Default: collapsed on mobile, expanded on desktop.
return typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
} catch { return false; }
});
const toggleSidebar = React.useCallback(() => {
setSidebarCollapsed(prev => {
const next = !prev;
try { localStorage.setItem('df.sidebar.collapsed', next ? '1' : '0'); } catch {}
return next;
});
}, []);
React.useEffect(() => {
document.documentElement.style.setProperty('--accent', ACCENT);
document.documentElement.style.setProperty('--accent-soft', hexToRgba(ACCENT, 0.14));
document.documentElement.style.setProperty('--accent-soft-2', hexToRgba(ACCENT, 0.22));
document.documentElement.style.setProperty('--accent-text', lighten(ACCENT, 0.25));
document.documentElement.style.setProperty('--accent-hover', lighten(ACCENT, 0.08));
}, []);
React.useEffect(() => {
window.ZAMPP_API.loadData()
.then(() => setDataReady(true))
.catch(err => { console.error('[Dragonflight] load failed:', err); setLoadError(err.message || 'Failed to load'); setDataReady(true); });
}, []);
const navigate = (id) => { setOpenAsset(null); setRoute(id); };
const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };
const crumbs = React.useMemo(() => {
if (openAsset) return [
{ label: 'Library', to: 'library' },
{ label: openAsset.project || 'Library', to: 'library' },
{ label: openAsset.name },
];
if (openProject) return [
{ label: 'Projects', to: 'projects' },
{ label: openProject.name },
];
const labels = {
home: ['Home'], dashboard: ['Dashboard'],
library: ['Library'], projects: ['Projects'],
upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
schedule: ['Ingest', 'Schedule'],
youtube: ['Ingest', 'YouTube'],
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
jobs: ['Jobs'], editor: ['Editor'],
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
containers: ['Admin', 'Containers'], cluster: ['Admin', 'Cluster'],
settings: ['Admin', 'Settings'],
};
return (labels[route] || ['Home']).map(label => ({ label }));
}, [route, openAsset, openProject]);
if (!dataReady) {
return (
<>
<style>{'@keyframes _df_spin{to{transform:rotate(360deg)}}'}</style>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', flexDirection: 'column', gap: 14, background: 'var(--bg-0)' }}>
<div style={{ width: 28, height: 28, borderRadius: '50%', border: '2px solid var(--border)', borderTopColor: ACCENT, animation: '_df_spin 0.8s linear infinite' }} />
<div style={{ fontSize: 12, color: 'var(--text-3)', fontFamily: 'var(--font-mono)' }}>Loading Dragonflight</div>
</div>
</>
);
}
if (loadError) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', flexDirection: 'column', gap: 12, background: 'var(--bg-0)' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--danger)' }}>Failed to load</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', maxWidth: 360, textAlign: 'center' }}>{loadError}</div>
<button className="btn primary sm" onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
let content;
if (openAsset) {
content = <AssetDetail asset={openAsset} onClose={() => setOpenAsset(null)} />;
} else {
switch (route) {
case 'home': content = <Home navigate={navigate} />; break;
case 'dashboard': content = <Dashboard navigate={navigate} />; break;
case 'library': content = <Library navigate={navigate} onOpenAsset={setOpenAsset} openProject={openProject} onClearProject={() => setOpenProject(null)} />; break;
case 'projects': content = <Projects navigate={navigate} onOpenProject={openProjectFromAnywhere} />; break;
case 'upload': content = <Upload navigate={navigate} />; break;
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
case 'schedule': content = <Schedule navigate={navigate} />; break;
case 'youtube': content = <YouTubeImport navigate={navigate} />; break;
case 'capture': content = <Capture navigate={navigate} />; break;
case 'monitors': content = <Monitors navigate={navigate} />; break;
case 'jobs': content = <Jobs navigate={navigate} />; break;
case 'editor': content = <Editor />; break;
case 'users': content = <Users />; break;
case 'tokens': content = <Tokens />; break;
case 'containers':content = <Containers />; break;
case 'cluster': content = <Cluster />; break;
case 'settings': content = <Settings />; break;
default: content = <Home navigate={navigate} />;
}
}
// Home (launcher) suppresses the topbar — it's a full-bleed landing page.
const hideTopbar = !openAsset && route === 'home';
// Account-settings modal — opened from sidebar's user button.
const [accountOpen, setAccountOpen] = React.useState(false);
React.useEffect(() => {
const open = () => setAccountOpen(true);
window.addEventListener('df:open-account-settings', open);
return () => window.removeEventListener('df:open-account-settings', open);
}, []);
return (
<div className="app" data-density="comfortable" data-grid-size="md" data-sidebar={sidebarCollapsed ? 'collapsed' : 'expanded'}>
<Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} me={window.ZAMPP_DATA?.ME} collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
<div className="main">
{!openAsset && !hideTopbar && (
<Topbar
crumbs={crumbs}
onNavigate={navigate}
onOpenAsset={setOpenAsset}
onOpenProject={openProjectFromAnywhere}
onToggleSidebar={toggleSidebar}
/>
)}
{content}
</div>
{showNewRecorder && <NewRecorderModal open={showNewRecorder} onClose={() => setShowNewRecorder(false)} />}
{accountOpen && window.AccountSettingsModal && (
<window.AccountSettingsModal onClose={() => setAccountOpen(false)} />
)}
</div>
);
}
function hexToRgba(hex, a) {
const h = hex.replace('#', '');
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
function lighten(hex, amt) {
const h = hex.replace('#', '');
const r = Math.min(255, parseInt(h.slice(0, 2), 16) + Math.round(amt * 255));
const g = Math.min(255, parseInt(h.slice(2, 4), 16) + Math.round(amt * 255));
const b = Math.min(255, parseInt(h.slice(4, 6), 16) + Math.round(amt * 255));
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);