dragonflight/services/web-ui/public/modal-account-settings.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

149 lines
6.9 KiB
JavaScript

// Account Settings modal — self-service for the currently-signed-in user.
// Change display name + password. Username and role are admin-only.
function AccountSettingsModal({ onClose }) {
const me = window.ZAMPP_DATA?.ME || {};
const [tab, setTab] = React.useState('profile');
const [displayName, setDisplayName] = React.useState(me.display_name || me.name || '');
const [savingName, setSavingName] = React.useState(false);
const [nameMsg, setNameMsg] = React.useState(null);
const [curPw, setCurPw] = React.useState('');
const [newPw, setNewPw] = React.useState('');
const [newPw2, setNewPw2] = React.useState('');
const [savingPw, setSavingPw] = React.useState(false);
const [pwMsg, setPwMsg] = React.useState(null);
const [showNew, setShowNew] = React.useState(false);
const saveName = async (e) => {
e.preventDefault();
setNameMsg(null);
if (!displayName.trim()) { setNameMsg({ ok: false, text: 'Display name cannot be empty' }); return; }
setSavingName(true);
try {
const r = await window.ZAMPP_API.fetch('/auth/me', {
method: 'PATCH',
body: JSON.stringify({ display_name: displayName.trim() }),
});
// refresh ME cache
if (window.ZAMPP_DATA) {
window.ZAMPP_DATA.ME = { ...(window.ZAMPP_DATA.ME || {}), display_name: r.display_name, name: r.display_name };
}
setNameMsg({ ok: true, text: 'Display name updated' });
} catch (err) {
setNameMsg({ ok: false, text: err.message || 'Update failed' });
} finally { setSavingName(false); }
};
const savePw = async (e) => {
e.preventDefault();
setPwMsg(null);
if (newPw !== newPw2) { setPwMsg({ ok: false, text: 'New passwords do not match' }); return; }
if (newPw.length < 8) { setPwMsg({ ok: false, text: 'Password must be at least 8 characters' }); return; }
setSavingPw(true);
try {
await window.ZAMPP_API.fetch('/auth/password', {
method: 'POST',
body: JSON.stringify({ current_password: curPw, new_password: newPw }),
});
setPwMsg({ ok: true, text: 'Password updated — all other sessions signed out' });
setCurPw(''); setNewPw(''); setNewPw2('');
} catch (err) {
setPwMsg({ ok: false, text: err.message || 'Update failed' });
} finally { setSavingPw(false); }
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<div style={{ fontWeight: 600 }}>Account settings</div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>
{me.username || '—'} · {me.role || '—'}{me.is_client ? ' · client' : ''}
</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div style={{ display: 'flex', gap: 4, padding: '0 16px', borderBottom: '1px solid var(--border)' }}>
{[
{ id: 'profile', label: 'Profile' },
{ id: 'password', label: 'Password' },
].map(t => (
<button
key={t.id}
className="btn ghost sm"
onClick={() => setTab(t.id)}
style={{
background: tab === t.id ? 'var(--accent-soft)' : 'transparent',
color: tab === t.id ? 'var(--accent-text)' : 'var(--text-2)',
border: 0, borderBottom: '2px solid ' + (tab === t.id ? 'var(--accent)' : 'transparent'),
borderRadius: 0,
}}
>{t.label}</button>
))}
</div>
<div style={{ padding: 16 }}>
{tab === 'profile' && (
<form onSubmit={saveName} autoComplete="off">
<div className="field">
<label className="field-label">Username</label>
<input className="field-input mono" value={me.username || ''} readOnly />
</div>
<div className="field">
<label className="field-label">Display name</label>
<input className="field-input" value={displayName} onChange={e => setDisplayName(e.target.value)} maxLength={120} />
</div>
{nameMsg && (
<div style={{ fontSize: 12, color: nameMsg.ok ? 'var(--success)' : 'var(--danger)', marginTop: 6 }}>
{nameMsg.text}
</div>
)}
<div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
<button type="submit" className="btn primary sm" disabled={savingName}>{savingName ? 'Saving…' : 'Save'}</button>
</div>
</form>
)}
{tab === 'password' && (
<form onSubmit={savePw} autoComplete="off">
<div className="field">
<label className="field-label">Current password</label>
<input className="field-input" type="password" value={curPw} onChange={e => setCurPw(e.target.value)} autoComplete="current-password" required />
</div>
<div className="field">
<label className="field-label">New password</label>
<div style={{ position: 'relative' }}>
<input className="field-input" type={showNew ? 'text' : 'password'} value={newPw} onChange={e => setNewPw(e.target.value)} autoComplete="new-password" required minLength={8} style={{ paddingRight: 36 }} />
<button type="button" tabIndex={-1} className="icon-btn"
aria-label={showNew ? 'Hide password' : 'Show password'}
style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
onClick={() => setShowNew(s => !s)}>
<Icon name={showNew ? 'eye-off' : 'eye'} size={13} />
</button>
</div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>8+ chars, mixed case, digit, symbol</div>
</div>
<div className="field">
<label className="field-label">Confirm new password</label>
<input className="field-input" type="password" value={newPw2} onChange={e => setNewPw2(e.target.value)} autoComplete="new-password" required minLength={8} />
</div>
{pwMsg && (
<div style={{ fontSize: 12, color: pwMsg.ok ? 'var(--success)' : 'var(--danger)', marginTop: 6 }}>
{pwMsg.text}
</div>
)}
<div style={{ display: 'flex', gap: 6, marginTop: 12 }}>
<button type="submit" className="btn primary sm" disabled={savingPw}>{savingPw ? 'Updating…' : 'Update password'}</button>
</div>
</form>
)}
</div>
</div>
</div>
);
}
window.AccountSettingsModal = AccountSettingsModal;