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
149 lines
6.9 KiB
JavaScript
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;
|