This commit is contained in:
Devin Zuczek
2026-07-09 00:56:00 -04:00
parent 7f57ee8fda
commit f0a273a3c4
17 changed files with 15584 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
import { useCallback, useEffect, useState } from 'react'
/** The self-account shape returned by the accounts worker (`GET /account/me`). */
interface SelfAccount {
accountId: number
username: string
displayName: string
email: string | null
}
/**
* Call a www BFF endpoint. GET when no body is given, else POST JSON. Throws with
* the upstream error message (auth uses `error`/`error_description`, the account
* mutations use `error`) so callers can surface it.
*/
async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method: body === undefined ? 'GET' : 'POST',
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (!res.ok) {
const message =
(typeof data.error === 'string' && data.error) ||
(typeof data.error_description === 'string' && data.error_description) ||
`Request failed (${res.status})`
throw new Error(message)
}
return data as T
}
export function App() {
// undefined = still checking the session; null = signed out.
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
useEffect(() => {
api<{ accountId: number } & SelfAccount>('/api/me')
.then((me) => setAccount(me))
.catch(() => setAccount(null))
}, [])
const logout = useCallback(async () => {
await api('/api/logout', {})
setAccount(null)
}, [])
return (
<main className="shell">
<h1>Recflare Accounts</h1>
{account === undefined ? (
<p className="muted">Loading</p>
) : account ? (
<Dashboard account={account} onChange={setAccount} onLogout={logout} />
) : (
<AuthForms onAuthed={setAccount} />
)}
</main>
)
}
/** Small hook wrapping a submit handler with pending/error/success state. */
function useAction() {
const [pending, setPending] = useState(false)
const [error, setError] = useState('')
const [done, setDone] = useState('')
const run = useCallback(async (fn: () => Promise<string>) => {
setPending(true)
setError('')
setDone('')
try {
setDone(await fn())
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setPending(false)
}
}, [])
return { pending, error, done, run }
}
function AuthForms({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
const [tab, setTab] = useState<'signup' | 'login'>('signup')
return (
<section className="card">
<div className="tabs">
<button className={tab === 'signup' ? 'active' : ''} onClick={() => setTab('signup')}>
Create account
</button>
<button className={tab === 'login' ? 'active' : ''} onClick={() => setTab('login')}>
Sign in
</button>
</div>
{tab === 'signup' ? <SignupForm onAuthed={onAuthed} /> : <LoginForm onAuthed={onAuthed} />}
</section>
)
}
function SignupForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
const [password, setPassword] = useState('')
const { pending, error, run } = useAction()
return (
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { account } = await api<{ account: SelfAccount }>('/api/signup', { password })
onAuthed(account)
return ''
})
}}
>
<p className="muted">
A new account id is assigned automatically. Choose a password to sign in later.
</p>
<label>
Password
<input
type="password"
value={password}
autoComplete="new-password"
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Creating…' : 'Create account'}
</button>
</form>
)
}
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
const [accountId, setAccountId] = useState('')
const [password, setPassword] = useState('')
const { pending, error, run } = useAction()
return (
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { account } = await api<{ account: SelfAccount }>('/api/login', {
accountId,
password,
})
onAuthed(account)
return ''
})
}}
>
<label>
Account id
<input
type="text"
inputMode="numeric"
value={accountId}
autoComplete="username"
onChange={(e) => setAccountId(e.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Signing in…' : 'Sign in'}
</button>
</form>
)
}
function Dashboard({
account,
onChange,
onLogout,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
onLogout: () => void
}) {
return (
<>
<section className="card">
<div className="row">
<div>
<div className="muted">Signed in as</div>
<div className="big">
{account.displayName || account.username}{' '}
<span className="muted">#{account.accountId}</span>
</div>
<div className="muted">{account.email ?? 'no email set'}</div>
</div>
<button className="ghost" onClick={onLogout}>
Sign out
</button>
</div>
</section>
<EmailForm account={account} onChange={onChange} />
<PasswordForm />
</>
)
}
function EmailForm({
account,
onChange,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
}) {
const [email, setEmail] = useState(account.email ?? '')
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Email</h2>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await api('/api/email', { email })
onChange({ ...account, email })
return 'Email saved.'
})
}}
>
<label>
Email address
<input
type="email"
value={email}
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Saving…' : 'Save email'}
</button>
</form>
</section>
)
}
function PasswordForm() {
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Password</h2>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
await api('/api/password', { oldPassword, newPassword })
setOldPassword('')
setNewPassword('')
return 'Password changed.'
})
}}
>
<label>
Current password
<input
type="password"
value={oldPassword}
autoComplete="current-password"
onChange={(e) => setOldPassword(e.target.value)}
required
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
autoComplete="new-password"
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Updating…' : 'Change password'}
</button>
</form>
</section>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './styles.css'
const root = document.getElementById('root')
if (!root) throw new Error('missing #root element')
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
)
+167
View File
@@ -0,0 +1,167 @@
:root {
color-scheme: light dark;
--bg: #f5f6f8;
--card: #ffffff;
--text: #1a1d21;
--muted: #6b7280;
--border: #e2e5ea;
--accent: #4f46e5;
--accent-text: #ffffff;
--error: #dc2626;
--ok: #16a34a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--card: #191c22;
--text: #e6e8eb;
--muted: #9aa1ab;
--border: #2a2e37;
--accent: #6366f1;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font:
15px/1.5 system-ui,
-apple-system,
Segoe UI,
Roboto,
sans-serif;
}
.shell {
max-width: 460px;
margin: 0 auto;
padding: 48px 20px 64px;
}
h1 {
font-size: 1.5rem;
margin: 0 0 20px;
}
h2 {
font-size: 1rem;
margin: 0 0 12px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tabs button {
flex: 1;
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
padding: 8px;
border-radius: 8px;
cursor: pointer;
}
.tabs button.active {
border-color: var(--accent);
color: var(--text);
font-weight: 600;
}
label {
display: block;
margin-bottom: 12px;
font-size: 0.85rem;
color: var(--muted);
}
input {
display: block;
width: 100%;
margin-top: 4px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 0.95rem;
}
input:focus {
outline: 2px solid var(--accent);
outline-offset: 0;
border-color: transparent;
}
button[type='submit'],
.ghost {
border: none;
border-radius: 8px;
padding: 10px 16px;
font-size: 0.95rem;
cursor: pointer;
}
button[type='submit'] {
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
width: 100%;
}
button[type='submit']:disabled {
opacity: 0.6;
cursor: default;
}
.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
.row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.big {
font-size: 1.1rem;
font-weight: 600;
margin: 2px 0;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
}
.error {
color: var(--error);
font-size: 0.85rem;
margin: 0 0 12px;
}
.ok {
color: var(--ok);
font-size: 0.85rem;
margin: 0 0 12px;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />