// 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 (
Dragonflight
Wild Dragon Broadcast
); } function Card({ children }) { return (
{children}
); } function Field({ label, type = 'text', value, onChange, autoComplete, autoFocus }) { return (
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', }} />
); } function Button({ children, disabled, onClick, type = 'button' }) { return ( ); } function ErrorRow({ text }) { if (!text) return null; return (
{text}
); } function Screen({ children }) { return (
e.preventDefault()} style={{ width: 300 }}> {children}
); } // 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 ( G Sign in with Google ); } function Divider() { return (
or
); } 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 (
Two-factor authentication
Enter the 6-digit code from your authenticator app, or a recovery code.
); } return ( {googleEnabled && <>} ); } 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 (
First-run setup: create the first admin
); } window.LoginScreen = LoginScreen; window.SetupScreen = SetupScreen; })();