dragonflight/services/web-ui/public/screens-auth.jsx

284 lines
11 KiB
React
Raw Permalink Normal View History

ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155) Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal codec stubs) deferred per user request. ## #146 sweep em-dashes (186 to 0) - Replace placeholder '—' with '·' across all jsx - Convert ' — ' to ': ' or '. ' in copy where context permits - Comment-only em-dashes converted to ASCII dash - Sweep css files too (16 comments) ## #147 remove glassmorphism + accent gradients - Strip 8 backdrop-filter declarations from styles-screens.css and styles-asset.css. Only legit modal scrim in styles-modal.css remains. - Replace .job-progress-fill gradient with solid var(--accent) - Replace .monitor-tile.audio gradient with flat var(--bg-1) ## #148 extract Jobs inline styles to CSS - Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic width on progress bar). Live DOM was 487 inline-styled elements due to per-row repetition; now ~0. - Added job-row-kind, job-row-asset, job-row-node, job-row-time, job-row-actions, job-row-status-* utility classes in styles-screens.css ## #149 sidebar IA reorganized - Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS: Workspace / Ingest / Operations / Admin - Move Capture out of Ingest into Operations (it's a live-signal monitor, not an ingest action) - Drop the 0/N capture badge from nav (belongs in topbar) - Add BETA badge to Editor ## #151 redesign Editor 'Coming Soon' bumper - Replace fullscreen glassmorphism + gradient + glow overlay with a flat beta banner across the top of the editor area - New .editor-beta-banner CSS class (flat, accent-soft tint, no blur) ## #152 hide Tokens parody, restore real API token mgmt - New top-level Tokens admin page wraps existing ApiTokensSection - Old parody renamed to TokensParody, accessible at /tokens-parody route - Add window-level df:nav event for cross-component routing ## #153 make Home actually useful - New activity strip below the launcher grid: 'Recording now' tiles for live recorders, 'Last 24 hours' tiles for newly created assets, plus an attention strip when there are failed jobs or errored recorders - Each item is clickable and routes to the relevant screen ## #154 aria-labels on icon-only buttons - Projects + Library grid/list view toggles now have aria-label + title ## #155 page-header pattern - Dashboard now renders a proper .page-header h1 with subtitle + alert badge + cluster status pip - Library toolbar-title promoted to h1 for screen-reader hierarchy - Document Home/Library/Editor full-bleed exceptions in DESIGN.md - Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
// LoginScreen + SetupScreen - layout B from the auth brainstorm spec:
// 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card.
// Matches DESIGN.md tokens; no decoration, dense, ops register.
(function () {
const API_BASE = '/api/v1';
async function postJson(path, body) {
return fetch(API_BASE + path, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'dragonflight-ui',
},
body: JSON.stringify(body),
});
}
function Brand() {
return (
<div style={{ textAlign: 'center', marginBottom: 22 }}>
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--text-1)', letterSpacing: '-0.01em' }}>Dragonflight</div>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', marginTop: 6, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
Wild Dragon Broadcast
</div>
</div>
);
}
function Card({ children }) {
return (
<div style={{
background: 'var(--bg-1)',
border: '1px solid var(--border)',
borderRadius: 12,
padding: 22,
}}>{children}</div>
);
}
function Field({ label, type = 'text', value, onChange, autoComplete, autoFocus }) {
return (
<div style={{ marginBottom: 14 }}>
<label style={{ display: 'block', fontSize: 10.5, fontWeight: 600, color: 'var(--text-2)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 6 }}>
{label}
</label>
<input
type={type}
autoComplete={autoComplete}
autoFocus={autoFocus}
value={value}
onChange={e => onChange(e.target.value)}
style={{
width: '100%',
background: 'var(--bg-3)',
border: '1px solid var(--border)',
borderRadius: 4,
padding: '8px 10px',
fontSize: 12.5,
color: 'var(--text-1)',
fontFamily: 'var(--font-mono)',
boxSizing: 'border-box',
}}
/>
</div>
);
}
function Button({ children, disabled, onClick, type = 'button' }) {
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
style={{
width: '100%',
background: disabled ? 'var(--bg-3)' : 'var(--accent)',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '9px',
fontSize: 13,
fontWeight: 600,
fontFamily: 'inherit',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
}}
>{children}</button>
);
}
function ErrorRow({ text }) {
if (!text) return null;
return (
<div style={{
fontSize: 11.5,
color: 'var(--danger)',
marginBottom: 12,
padding: '6px 10px',
background: 'var(--danger-soft)',
border: '1px solid var(--danger)',
borderRadius: 4,
}}>{text}</div>
);
}
function Screen({ children }) {
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<form onSubmit={e => e.preventDefault()} style={{ width: 300 }}>
<Brand />
<Card>{children}</Card>
</form>
</div>
);
}
// Google sign-in availability + a friendly message for the callback's
// ?auth_error redirect (domain-not-allowed / generic google failure).
function useGoogleAndAuthError(setError) {
const [googleEnabled, setGoogleEnabled] = React.useState(false);
React.useEffect(() => {
fetch(API_BASE + '/auth/google/enabled', { credentials: 'include' })
.then(r => r.json()).then(d => setGoogleEnabled(!!d.enabled)).catch(() => {});
const params = new URLSearchParams(location.search);
const e = params.get('auth_error');
if (e === 'domain') setError('That Google account is not in an allowed domain.');
else if (e === 'google') setError('Google sign-in failed. Please try again.');
if (e) {
// Clean the query string so a reload doesn't re-show the error.
const url = location.pathname + location.hash;
history.replaceState(null, '', url);
}
}, [setError]);
return googleEnabled;
}
function GoogleButton() {
return (
<a href={API_BASE + '/auth/google'} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
width: '100%', boxSizing: 'border-box', textDecoration: 'none',
background: 'var(--bg-3)', color: 'var(--text-1)',
border: '1px solid var(--border)', borderRadius: 4,
padding: '9px', fontSize: 13, fontWeight: 600, marginTop: 10,
}}>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 700 }}>G</span>
Sign in with Google
</a>
);
}
function Divider() {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '14px 0 4px' }}>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
<span style={{ fontSize: 10, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>or</span>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
</div>
);
}
function LoginScreen({ onDone }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
// Second factor: when the server returns { mfa_required, ticket }, we switch
// to the code step instead of completing login. `ticket` may be a real value
// (password path) or the sentinel 'session' (Google path, where the ticket
// lives in the session cookie and is not exposed to JS).
const [ticket, setTicket] = React.useState(null);
const [code, setCode] = React.useState('');
const googleEnabled = useGoogleAndAuthError(setError);
// Google OAuth with TOTP redirects back to /?mfa=1; the ticket is in the
// session, so enter the code step without a body ticket.
React.useEffect(() => {
const params = new URLSearchParams(location.search);
if (params.get('mfa') === '1') {
setTicket('session');
history.replaceState(null, '', location.pathname + location.hash);
}
}, []);
const submit = async () => {
setError(''); setBusy(true);
try {
const r = await postJson('/auth/login', { username, password });
if (r.status === 200) {
const body = await r.json().catch(() => ({}));
if (body.mfa_required) { setTicket(body.ticket); setBusy(false); return; }
onDone(); return;
}
const body = await r.json().catch(() => ({}));
setError(body.error || ('Login failed: ' + r.status));
} catch (e) { setError(e.message || 'Login failed'); }
finally { setBusy(false); }
};
const submitCode = async () => {
setError(''); setBusy(true);
try {
// For the Google path the ticket is the session sentinel — send code only.
const payload = ticket === 'session' ? { code: code.trim() } : { ticket, code: code.trim() };
const r = await postJson('/auth/login/totp', payload);
if (r.status === 200) { onDone(); return; }
const body = await r.json().catch(() => ({}));
// An expired/used ticket means the user must start over.
if (r.status === 401 && /ticket/.test(body.error || '')) {
setTicket(null); setCode(''); setPassword('');
setError('Session timed out — please sign in again.');
} else {
setError(body.error || ('Verification failed: ' + r.status));
}
} catch (e) { setError(e.message || 'Verification failed'); }
finally { setBusy(false); }
};
if (ticket) {
return (
<Screen>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
Two-factor authentication
</div>
<ErrorRow text={error} />
<Field label="Authenticator code" value={code} onChange={setCode} autoComplete="one-time-code" autoFocus />
<div style={{ fontSize: 10.5, color: 'var(--text-3)', marginBottom: 12 }}>
Enter the 6-digit code from your authenticator app, or a recovery code.
</div>
<Button type="submit" disabled={busy || !code.trim()} onClick={submitCode}>Verify</Button>
</Screen>
);
}
return (
<Screen>
<ErrorRow text={error} />
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
{googleEnabled && <><Divider /><GoogleButton /></>}
</Screen>
);
}
function SetupScreen({ onDone }) {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [confirm, setConfirm] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
const submit = async () => {
setError('');
if (password !== confirm) { setError('Passwords do not match'); return; }
if (password.length < 12) { setError('Password must be at least 12 characters'); return; }
setBusy(true);
try {
const r = await postJson('/auth/setup', { username, password });
if (r.status === 200) { onDone(); return; }
const body = await r.json().catch(() => ({}));
setError(body.error || ('Setup failed: ' + r.status));
} catch (e) { setError(e.message || 'Setup failed'); }
finally { setBusy(false); }
};
return (
<Screen>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155) Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal codec stubs) deferred per user request. ## #146 sweep em-dashes (186 to 0) - Replace placeholder '—' with '·' across all jsx - Convert ' — ' to ': ' or '. ' in copy where context permits - Comment-only em-dashes converted to ASCII dash - Sweep css files too (16 comments) ## #147 remove glassmorphism + accent gradients - Strip 8 backdrop-filter declarations from styles-screens.css and styles-asset.css. Only legit modal scrim in styles-modal.css remains. - Replace .job-progress-fill gradient with solid var(--accent) - Replace .monitor-tile.audio gradient with flat var(--bg-1) ## #148 extract Jobs inline styles to CSS - Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic width on progress bar). Live DOM was 487 inline-styled elements due to per-row repetition; now ~0. - Added job-row-kind, job-row-asset, job-row-node, job-row-time, job-row-actions, job-row-status-* utility classes in styles-screens.css ## #149 sidebar IA reorganized - Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS: Workspace / Ingest / Operations / Admin - Move Capture out of Ingest into Operations (it's a live-signal monitor, not an ingest action) - Drop the 0/N capture badge from nav (belongs in topbar) - Add BETA badge to Editor ## #151 redesign Editor 'Coming Soon' bumper - Replace fullscreen glassmorphism + gradient + glow overlay with a flat beta banner across the top of the editor area - New .editor-beta-banner CSS class (flat, accent-soft tint, no blur) ## #152 hide Tokens parody, restore real API token mgmt - New top-level Tokens admin page wraps existing ApiTokensSection - Old parody renamed to TokensParody, accessible at /tokens-parody route - Add window-level df:nav event for cross-component routing ## #153 make Home actually useful - New activity strip below the launcher grid: 'Recording now' tiles for live recorders, 'Last 24 hours' tiles for newly created assets, plus an attention strip when there are failed jobs or errored recorders - Each item is clickable and routes to the relevant screen ## #154 aria-labels on icon-only buttons - Projects + Library grid/list view toggles now have aria-label + title ## #155 page-header pattern - Dashboard now renders a proper .page-header h1 with subtitle + alert badge + cluster status pip - Library toolbar-title promoted to h1 for screen-reader hierarchy - Document Home/Library/Editor full-bleed exceptions in DESIGN.md - Editor's chrome is the beta banner (covered by #151)
2026-05-28 19:50:07 -04:00
First-run setup: create the first admin
</div>
<ErrorRow text={error} />
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="new-password" />
<Field label="Confirm password" type="password" value={confirm} onChange={setConfirm} autoComplete="new-password" />
<Button type="submit" disabled={busy || !username || !password || !confirm} onClick={submit}>Create admin</Button>
</Screen>
);
}
window.LoginScreen = LoginScreen;
window.SetupScreen = SetupScreen;
})();