Optional "Sign in with Google" with auto-provisioning, fully config-gated: without GOOGLE_CLIENT_ID/SECRET and OAUTH_REDIRECT_URL the routes 404 and the button is hidden, so deployments without SSO are unaffected. - migration 028: users.google_sub (unique) + email; password_hash nullable for OAuth-only accounts - src/auth/google-oauth.js: lazy google-auth-library, ID-token verify, GOOGLE_ALLOWED_DOMAIN enforcement, requires email_verified === true - auth routes: /auth/google (state-CSRF redirect), /auth/google/callback, /auth/google/enabled; reuses establishSession - web-ui: "Sign in with Google" on the login screen (shown only when enabled), friendly callback error handling - .env.example documents all new vars Security hardening (from review of this + the TOTP work): - resolveGoogleUser links ONLY by google_sub, never by email — a Google login can never seize a pre-existing local account (account-takeover fix) - a Google-linked account with TOTP still requires the second factor (ticket in session, /?mfa=1 step) instead of bypassing it - /login/totp now applies the per-IP login backoff - recovery-code consumption is atomic (WHERE used_at IS NULL + rowCount) - concurrent first-login race on google_sub is caught and re-resolved - tests: google-oauth config helpers + google-link takeover/dedup regression Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
// 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 }}>
|
|
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;
|
|
})();
|