mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
wip web
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
/** Base domain the auth/accounts hosts are derived from (see wrangler.jsonc). */
|
||||
DOMAIN: string
|
||||
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
||||
ASSETS: Fetcher
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
export type Variables = SharedHonoVariables
|
||||
|
||||
export interface App extends HonoApp {
|
||||
Bindings: Env
|
||||
Variables: Variables
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SELF } from 'cloudflare:test'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it('rejects unauthenticated account reads', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/me')
|
||||
expect(res.status).toBe(401)
|
||||
expect(await res.json()).toEqual({ error: 'not signed in' })
|
||||
})
|
||||
|
||||
it('requires a password to sign up', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'A password is required.' })
|
||||
})
|
||||
|
||||
it('requires credentials to log in', async () => {
|
||||
const res = await SELF.fetch('https://example.com/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ accountId: '1' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'Account id and password are required.' })
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Env } from './context'
|
||||
|
||||
/**
|
||||
* The www worker is a backend-for-frontend (BFF): the browser only ever talks to
|
||||
* www, and www forwards to the `auth` and `accounts` workers server-side. That
|
||||
* keeps the JWT off other origins and sidesteps CORS (those workers set no CORS
|
||||
* headers). Hosts are derived from the shared base domain (`auth.<DOMAIN>`,
|
||||
* `accounts.<DOMAIN>`), matching how the workers are deployed.
|
||||
*/
|
||||
|
||||
export const authBase = (env: Env): string => `https://auth.${env.DOMAIN}`
|
||||
export const accountsBase = (env: Env): string => `https://accounts.${env.DOMAIN}`
|
||||
|
||||
/**
|
||||
* POST a form-urlencoded body to an upstream worker. The auth/accounts endpoints
|
||||
* read their inputs via Hono's `parseBody()`, so they expect form fields (not
|
||||
* JSON). `bearer`, when given, authenticates the caller.
|
||||
*/
|
||||
export async function postForm(
|
||||
url: string,
|
||||
fields: Record<string, string>,
|
||||
bearer?: string
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
if (bearer) headers.authorization = `Bearer ${bearer}`
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Hono } from 'hono'
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { accountsBase, authBase, postForm } from './upstream'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { CookieOptions } from 'hono/utils/cookie'
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* www — the first frontend worker. It serves the React SPA (create account, set
|
||||
* email, change password) and acts as a backend-for-frontend: the browser talks
|
||||
* only to www, and www forwards to the `auth`/`accounts` workers server-side (see
|
||||
* `upstream.ts`). The account's JWT lives in an httpOnly cookie set here, so it's
|
||||
* never exposed to page JS.
|
||||
*/
|
||||
|
||||
/** Name of the httpOnly session cookie holding the account's access token. */
|
||||
const SESSION_COOKIE = 'rf_token'
|
||||
|
||||
/**
|
||||
* `create_account` needs a platform; RecNet (4) is the web platform. It's also
|
||||
* passed on credential logins for parity, though the auth worker ignores it there.
|
||||
*/
|
||||
const WEB_PLATFORM = '4'
|
||||
|
||||
/** Cookie flags for the session token. `secure` is dropped for local http dev. */
|
||||
function sessionCookieOptions(c: Context<App>, maxAge: number): CookieOptions {
|
||||
const local = c.env.ENVIRONMENT === 'development' || c.env.ENVIRONMENT === 'VITEST'
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: !local,
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge,
|
||||
}
|
||||
}
|
||||
|
||||
/** Pull the session token out of the request cookie, or null when absent. */
|
||||
function sessionToken(c: Context<App>): string | null {
|
||||
return getCookie(c, SESSION_COOKIE) ?? null
|
||||
}
|
||||
|
||||
/** Relay an upstream worker's JSON response back to the browser unchanged. */
|
||||
async function relay(c: Context<App>, res: Response) {
|
||||
const body = await res.text()
|
||||
return c.body(body, res.status as never, {
|
||||
'content-type': res.headers.get('content-type') ?? 'application/json',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an auth `/connect/token` response for a session: persist the returned
|
||||
* access token in the httpOnly cookie, then return the caller's self account
|
||||
* (fetched from the accounts worker with the fresh token).
|
||||
*/
|
||||
async function establishSession(c: Context<App>, tokenResponse: Response) {
|
||||
if (!tokenResponse.ok) return relay(c, tokenResponse)
|
||||
|
||||
const token = (await tokenResponse.json()) as { access_token?: string; expires_in?: number }
|
||||
if (!token.access_token) {
|
||||
return c.json({ error: 'auth did not return an access token' }, 502)
|
||||
}
|
||||
|
||||
setCookie(
|
||||
c,
|
||||
SESSION_COOKIE,
|
||||
token.access_token,
|
||||
sessionCookieOptions(c, token.expires_in ?? 3600)
|
||||
)
|
||||
|
||||
const me = await fetch(`${accountsBase(c.env)}/account/me`, {
|
||||
headers: { authorization: `Bearer ${token.access_token}` },
|
||||
})
|
||||
if (!me.ok) return c.json({ error: 'failed to load account after auth' }, 502)
|
||||
return c.json({ account: await me.json() })
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
// middleware
|
||||
(c, next) =>
|
||||
useWorkersLogger(c.env.NAME, {
|
||||
environment: c.env.ENVIRONMENT,
|
||||
release: c.env.SENTRY_RELEASE,
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
.onError(withOnError())
|
||||
|
||||
// ---- BFF API ------------------------------------------------------------
|
||||
|
||||
// Create a new account with a login password, then start a session.
|
||||
.post('/api/signup', async (c) => {
|
||||
const { password } = await c.req
|
||||
.json<{ password?: string }>()
|
||||
.catch(() => ({}) as { password?: string })
|
||||
if (!password) return c.json({ error: 'A password is required.' }, 400)
|
||||
|
||||
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
||||
grant_type: 'create_account',
|
||||
platform: WEB_PLATFORM,
|
||||
password,
|
||||
})
|
||||
return establishSession(c, res)
|
||||
})
|
||||
|
||||
// Log in with an existing account id + password, then start a session.
|
||||
.post('/api/login', async (c) => {
|
||||
const { accountId, password } = await c.req
|
||||
.json<{ accountId?: string; password?: string }>()
|
||||
.catch(() => ({}) as { accountId?: string; password?: string })
|
||||
if (!accountId || !password) {
|
||||
return c.json({ error: 'Account id and password are required.' }, 400)
|
||||
}
|
||||
|
||||
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
||||
grant_type: 'password',
|
||||
account_id: String(accountId),
|
||||
platform: WEB_PLATFORM,
|
||||
password,
|
||||
})
|
||||
return establishSession(c, res)
|
||||
})
|
||||
|
||||
// Clear the session cookie.
|
||||
.post('/api/logout', (c) => {
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
||||
return c.json({ success: true })
|
||||
})
|
||||
|
||||
// Current session's self account (used to restore UI state on page load).
|
||||
.get('/api/me', async (c) => {
|
||||
const token = sessionToken(c)
|
||||
if (!token) return c.json({ error: 'not signed in' }, 401)
|
||||
|
||||
const res = await fetch(`${accountsBase(c.env)}/account/me`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
// Token expired/invalid — drop the stale cookie so the client shows sign-in.
|
||||
if (res.status === 401) {
|
||||
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
||||
return c.json({ error: 'session expired' }, 401)
|
||||
}
|
||||
return relay(c, res)
|
||||
})
|
||||
|
||||
// Set the signed-in account's email.
|
||||
.post('/api/email', async (c) => {
|
||||
const token = sessionToken(c)
|
||||
if (!token) return c.json({ error: 'not signed in' }, 401)
|
||||
|
||||
const { email } = await c.req.json<{ email?: string }>().catch(() => ({}) as { email?: string })
|
||||
if (!email) return c.json({ error: 'An email is required.' }, 400)
|
||||
|
||||
const res = await postForm(`${accountsBase(c.env)}/account/me/email`, { email }, token)
|
||||
return relay(c, res)
|
||||
})
|
||||
|
||||
// Change the signed-in account's password (current password required).
|
||||
.post('/api/password', async (c) => {
|
||||
const token = sessionToken(c)
|
||||
if (!token) return c.json({ error: 'not signed in' }, 401)
|
||||
|
||||
const { oldPassword, newPassword } = await c.req
|
||||
.json<{ oldPassword?: string; newPassword?: string }>()
|
||||
.catch(() => ({}) as { oldPassword?: string; newPassword?: string })
|
||||
if (!newPassword) return c.json({ error: 'A new password is required.' }, 400)
|
||||
|
||||
const res = await postForm(
|
||||
`${authBase(c.env)}/account/me/changepassword`,
|
||||
{ oldPassword: oldPassword ?? '', newPassword },
|
||||
token
|
||||
)
|
||||
return relay(c, res)
|
||||
})
|
||||
|
||||
// ---- Static SPA ---------------------------------------------------------
|
||||
// Everything else is served from the built client assets. With
|
||||
// `not_found_handling: single-page-application`, unknown routes return
|
||||
// index.html so the React app can handle client-side routing.
|
||||
.all('*', (c) => {
|
||||
if (!c.env.ASSETS) return c.notFound()
|
||||
return c.env.ASSETS.fetch(c.req.raw)
|
||||
})
|
||||
|
||||
export default app
|
||||
Reference in New Issue
Block a user