Fix #13 add basic admin panel for now

This commit is contained in:
Devin Zuczek
2026-07-17 17:43:18 -04:00
parent b77389012e
commit 9065bf5e54
15 changed files with 901 additions and 238 deletions
+355 -86
View File
@@ -1,11 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
/** The self-account shape returned by the accounts worker (`GET /account/me`). */
/** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */
interface SelfAccount {
accountId: number
username: string
displayName: string
email: string | null
/** Whether this session may use admin controls (from the token's role claim). */
isAdmin?: boolean
}
/**
@@ -30,12 +32,59 @@ async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
return data as T
}
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
function useRouter() {
const [path, setPath] = useState(() => window.location.pathname)
useEffect(() => {
const onPop = () => setPath(window.location.pathname)
window.addEventListener('popstate', onPop)
return () => window.removeEventListener('popstate', onPop)
}, [])
const navigate = useCallback((to: string) => {
if (to !== window.location.pathname) {
window.history.pushState(null, '', to)
window.scrollTo(0, 0)
}
setPath(to)
}, [])
return { path, navigate }
}
type Navigate = (to: string) => void
/** An in-app link that routes client-side instead of doing a full page load. */
function Link({
to,
navigate,
className,
children,
}: {
to: string
navigate: Navigate
className?: string
children: ReactNode
}) {
return (
<a
href={to}
className={className}
onClick={(e) => {
e.preventDefault()
navigate(to)
}}
>
{children}
</a>
)
}
export function App() {
// undefined = still checking the session; null = signed out.
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
const { path, navigate } = useRouter()
useEffect(() => {
api<{ accountId: number } & SelfAccount>('/api/me')
api<SelfAccount>('/api/me')
.then((me) => setAccount(me))
.catch(() => setAccount(null))
}, [])
@@ -43,18 +92,183 @@ export function App() {
const logout = useCallback(async () => {
await api('/api/logout', {})
setAccount(null)
navigate('/')
}, [navigate])
return (
<>
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
{path === '/login' ? (
<LoginPage account={account} navigate={navigate} onAuthed={setAccount} />
) : path === '/account' ? (
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
) : (
<HomePage />
)}
</>
)
}
/** Top nav: brand → home, plus a sign-in / my-account link for the session. */
function NavBar({
account,
path,
navigate,
onLogout,
}: {
account: SelfAccount | null | undefined
path: string
navigate: Navigate
onLogout: () => void
}) {
return (
<header className="nav">
<Link to="/" navigate={navigate} className="brand">
RecFlare
</Link>
<nav className="nav-links">
{account === undefined ? null : account ? (
<>
<Link to="/account" navigate={navigate} className={path === '/account' ? 'active' : ''}>
My account
</Link>
<button className="linkish" onClick={onLogout}>
Sign out
</button>
</>
) : (
<Link to="/login" navigate={navigate} className={path === '/login' ? 'active' : ''}>
Sign in
</Link>
)}
</nav>
</header>
)
}
/** Public homepage: a slideshow of recent public photos. */
function HomePage() {
return (
<main className="shell wide">
<Slideshow />
</main>
)
}
/** A recent public image plus who took it and where. */
interface Slide {
url: string
username: string
roomName: string | null
}
function Slideshow() {
const [slides, setSlides] = useState<Slide[] | null>(null)
const [error, setError] = useState('')
const [idx, setIdx] = useState(0)
useEffect(() => {
api<{ images: Slide[] }>('/api/slideshow')
.then((d) => setSlides(d.images))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
}, [])
useEffect(() => {
if (!slides || slides.length < 2) return
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 5000)
return () => clearInterval(t)
}, [slides])
if (error) return <p className="error">Couldnt load the slideshow: {error}</p>
if (!slides) return <p className="muted">Loading</p>
if (slides.length === 0) return <p className="muted">No photos yet.</p>
const slide = slides[idx]
const step = (delta: number) => setIdx((i) => (i + delta + slides.length) % slides.length)
return (
<div className="slideshow">
<div className="slide-stage">
<img src={slide.url} alt={`Photo by ${slide.username}`} />
{slides.length > 1 && (
<>
<button className="slide-nav prev" onClick={() => step(-1)} aria-label="Previous photo">
</button>
<button className="slide-nav next" onClick={() => step(1)} aria-label="Next photo">
</button>
</>
)}
</div>
<div className="slide-meta">
<div>
<span className="big">@{slide.username}</span>
{slide.roomName && <span className="muted"> · {slide.roomName}</span>}
</div>
<div className="muted">
{idx + 1} / {slides.length}
</div>
</div>
</div>
)
}
/** The sign-in page. Redirects to the account page once a session exists. */
function LoginPage({
account,
navigate,
onAuthed,
}: {
account: SelfAccount | null | undefined
navigate: Navigate
onAuthed: (a: SelfAccount) => void
}) {
useEffect(() => {
if (account) navigate('/account')
}, [account, navigate])
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} />
)}
<section className="card">
<h2>Sign in</h2>
<LoginForm
onAuthed={(a) => {
onAuthed(a)
navigate('/account')
}}
/>
</section>
</main>
)
}
/** The signed-in account page. Redirects to sign-in when there's no session. */
function AccountPage({
account,
navigate,
onChange,
}: {
account: SelfAccount | null | undefined
navigate: Navigate
onChange: (a: SelfAccount) => void
}) {
useEffect(() => {
if (account === null) navigate('/login')
}, [account, navigate])
if (!account) {
return (
<main className="shell">
<p className="muted">{account === undefined ? 'Loading…' : 'Redirecting…'}</p>
</main>
)
}
return (
<main className="shell wide">
<h1>My account</h1>
<Dashboard account={account} onChange={onChange} />
</main>
)
}
@@ -81,61 +295,11 @@ function useAction() {
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>
)
}
// Manual web signups are disabled for now, so only sign-in is exposed (accounts are
// created via the game/platform, not the website). To bring signups back, restore a
// SignupForm calling POST /api/signup and re-enable that endpoint in www.app.ts.
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
const [accountId, setAccountId] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const { pending, error, run } = useAction()
@@ -145,7 +309,7 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
e.preventDefault()
void run(async () => {
const { account } = await api<{ account: SelfAccount }>('/api/login', {
accountId,
username,
password,
})
onAuthed(account)
@@ -154,13 +318,12 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
}}
>
<label>
Account id
Username
<input
type="text"
inputMode="numeric"
value={accountId}
value={username}
autoComplete="username"
onChange={(e) => setAccountId(e.target.value)}
onChange={(e) => setUsername(e.target.value)}
required
/>
</label>
@@ -185,35 +348,141 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
function Dashboard({
account,
onChange,
onLogout,
}: {
account: SelfAccount
onChange: (a: SelfAccount) => void
onLogout: () => void
}) {
// The dashboard sections, shown one at a time via the left tab rail. Admin-only
// sections are appended when the session carries an admin role.
const sections = [
{ id: 'email', label: 'Email', render: () => <EmailForm account={account} onChange={onChange} /> },
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
...(account.isAdmin
? [
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
]
: []),
]
const [active, setActive] = useState(sections[0].id)
const current = sections.find((s) => s.id === active) ?? sections[0]
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 className="muted">Signed in as</div>
<div className="big">
{account.displayName || account.username}{' '}
<span className="muted">#{account.accountId}</span>
</div>
<div className="muted">@{account.username}</div>
<div className="muted">{account.email ?? 'no email set'}</div>
</section>
<EmailForm account={account} onChange={onChange} />
<PasswordForm />
<div className="workspace">
<nav className="vtabs">
{sections.map((s) => (
<button
key={s.id}
className={s.id === active ? 'active' : ''}
onClick={() => setActive(s.id)}
>
{s.label}
</button>
))}
</nav>
<div className="panel">{current.render()}</div>
</div>
</>
)
}
/** Admin-only: send a coach/system message to every online player. */
function CoachMessageForm() {
const [message, setMessage] = useState('')
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Broadcast message</h2>
<p className="muted">
Send a message from the Coach to every connected player. Players who aren&apos;t online
won&apos;t receive it.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { sent } = await api<{ sent?: number }>('/api/coach-message', {
messageContent: message,
})
setMessage('')
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
})
}}
>
<label>
Message
<textarea
value={message}
rows={3}
onChange={(e) => setMessage(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send to all online'}
</button>
</form>
</section>
)
}
/** Admin-only: broadcast a server-maintenance countdown to every connected client. */
function MaintenanceForm() {
const [minutes, setMinutes] = useState('5')
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Server maintenance</h2>
<p className="muted">
Broadcast a maintenance countdown to every connected client. Enter how many minutes until
maintenance starts (0 = now).
</p>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const { connections } = await api<{ connections?: number }>('/api/maintenance', {
startsInMinutes: Number(minutes),
})
return `Notified ${connections ?? 0} connected client${connections === 1 ? '' : 's'}.`
})
}}
>
<label>
Starts in (minutes)
<input
type="number"
min="0"
step="1"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
required
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Broadcasting…' : 'Broadcast maintenance'}
</button>
</form>
</section>
)
}
function EmailForm({
account,
onChange,
+184 -43
View File
@@ -1,24 +1,28 @@
/* RecFlare brand: vivid orange (#FE7101) on warm cream (#FCEFDC), sampled from the logo. */
:root {
color-scheme: light dark;
--bg: #f5f6f8;
--bg: #fdf3e7;
--card: #ffffff;
--text: #1a1d21;
--muted: #6b7280;
--border: #e2e5ea;
--accent: #4f46e5;
--text: #33241a;
--muted: #8a7360;
--border: #efe0cc;
--accent: #fe7101;
--accent-hover: #e86400;
--accent-text: #ffffff;
--error: #dc2626;
--ok: #16a34a;
--ok: #15803d;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--card: #191c22;
--text: #e6e8eb;
--muted: #9aa1ab;
--border: #2a2e37;
--accent: #6366f1;
--bg: #17120d;
--card: #221a12;
--text: #f4eadb;
--muted: #b49b84;
--border: #362a1e;
--accent: #ff8320;
--accent-hover: #ff9540;
--ok: #22c55e;
}
}
@@ -41,7 +45,113 @@ body {
.shell {
max-width: 460px;
margin: 0 auto;
padding: 48px 20px 64px;
padding: 32px 20px 64px;
}
/* Widened for the account tab layout and the homepage slideshow. */
.shell.wide {
max-width: 760px;
}
/* Top navigation, shown on every page. */
.nav {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 760px;
margin: 0 auto;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 700;
font-size: 1.15rem;
color: var(--accent);
text-decoration: none;
letter-spacing: -0.01em;
}
.nav-links {
display: flex;
align-items: center;
gap: 16px;
}
.nav-links a {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
.nav-links a:hover,
.nav-links a.active {
color: var(--text);
}
.linkish {
background: none;
border: none;
padding: 0;
color: var(--muted);
font-size: 0.9rem;
cursor: pointer;
}
.linkish:hover {
color: var(--text);
}
/* Homepage slideshow. */
.slide-stage {
position: relative;
aspect-ratio: 16 / 9;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.slide-stage img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.slide-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 1.6rem;
line-height: 1;
cursor: pointer;
}
.slide-nav:hover {
background: rgba(0, 0, 0, 0.65);
}
.slide-nav.prev {
left: 12px;
}
.slide-nav.next {
right: 12px;
}
.slide-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-top: 14px;
}
h1 {
@@ -62,28 +172,64 @@ h2 {
margin-bottom: 16px;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
/* Signed-in layout: a vertical tab rail on the left, the active section on the right. */
.workspace {
display: grid;
grid-template-columns: 190px 1fr;
gap: 16px;
align-items: start;
}
.tabs button {
flex: 1;
.vtabs {
display: flex;
flex-direction: column;
gap: 4px;
}
.vtabs button {
text-align: left;
background: transparent;
border: 1px solid var(--border);
border: 1px solid transparent;
color: var(--muted);
padding: 8px;
padding: 9px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
}
.tabs button.active {
border-color: var(--accent);
.vtabs button:hover {
color: var(--text);
background: var(--card);
}
.vtabs button.active {
background: var(--card);
border-color: var(--border);
color: var(--text);
font-weight: 600;
}
/* The active section's card already carries its own margin; drop it inside the panel. */
.panel .card {
margin-bottom: 0;
}
/* Stack the rail above the panel on narrow screens. */
@media (max-width: 620px) {
.workspace {
grid-template-columns: 1fr;
}
.vtabs {
flex-direction: row;
flex-wrap: wrap;
}
.vtabs button {
border-color: var(--border);
}
}
label {
display: block;
margin-bottom: 12px;
@@ -91,7 +237,8 @@ label {
color: var(--muted);
}
input {
input,
textarea {
display: block;
width: 100%;
margin-top: 4px;
@@ -101,48 +248,42 @@ input {
background: var(--bg);
color: var(--text);
font-size: 0.95rem;
font-family: inherit;
}
input:focus {
textarea {
resize: vertical;
min-height: 76px;
}
input:focus,
textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 0;
border-color: transparent;
}
button[type='submit'],
.ghost {
button[type='submit'] {
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']:not(:disabled):hover {
background: var(--accent-hover);
}
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;
+26 -6
View File
@@ -7,22 +7,42 @@ it('rejects unauthenticated account reads', async () => {
expect(await res.json()).toEqual({ error: 'not signed in' })
})
it('requires a password to sign up', async () => {
it('refuses manual signups (disabled)', async () => {
const res = await SELF.fetch('https://example.com/api/signup', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
body: JSON.stringify({ password: 'whatever' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'A password is required.' })
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'Account creation is currently disabled.' })
})
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' }),
body: JSON.stringify({ username: 'alice' }),
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'Account id and password are required.' })
expect(await res.json()).toEqual({ error: 'Username and password are required.' })
})
it('rejects an unauthenticated maintenance broadcast', async () => {
const res = await SELF.fetch('https://example.com/api/maintenance', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ startsInMinutes: 15 }),
})
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
})
it('rejects an unauthenticated coach message', async () => {
const res = await SELF.fetch('https://example.com/api/coach-message', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ messageContent: 'hello all' }),
})
expect(res.status).toBe(401)
expect(await res.json()).toEqual({ error: 'not signed in' })
})
+3
View File
@@ -10,6 +10,9 @@ import type { Env } from './context'
export const authBase = (env: Env): string => `https://auth.${env.DOMAIN}`
export const accountsBase = (env: Env): string => `https://accounts.${env.DOMAIN}`
export const notifyBase = (env: Env): string => `https://notify.${env.DOMAIN}`
export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
/**
* POST a form-urlencoded body to an upstream worker. The auth/accounts endpoints
+126 -26
View File
@@ -4,7 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { withOnError } from '@repo/hono-helpers'
import { accountsBase, authBase, postForm } from './upstream'
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
import type { Context } from 'hono'
import type { CookieOptions } from 'hono/utils/cookie'
@@ -22,11 +22,23 @@ import type { App } from './context'
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.
* RecNet (4) is the web platform, stamped as the token's `platform` claim on login.
* NOT passed on signup: create_account treats an asserted platform as one to verify
* against Steam and rejects RecNet — the web signup is the (platform-less) password
* account path.
*/
const WEB_PLATFORM = '4'
/**
* Roles that unlock the admin controls in the UI. Mirrors the notify worker's
* `ADMIN_ROLES` gate — www only decides whether to *show* the controls; notify does
* the real enforcement (it verifies the token) on every call.
*/
const ADMIN_ROLES = new Set(['developer', 'moderator'])
/** `NotificationType.ServerMaintenance` in the notify worker's enum. */
const SERVER_MAINTENANCE = 25
/** 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'
@@ -44,6 +56,25 @@ function sessionToken(c: Context<App>): string | null {
return getCookie(c, SESSION_COOKIE) ?? null
}
/**
* Whether the session token carries an admin role. Decodes the JWT's `role` claim
* WITHOUT verifying — www holds no signing key, and this only gates whether admin UI
* is shown; the notify worker verifies the token before acting on it. A malformed
* token simply reads as "not admin".
*/
function isAdminToken(token: string): boolean {
const payload = token.split('.')[1]
if (!payload) return false
try {
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
const claims = JSON.parse(atob(padded)) as { role?: unknown }
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
} catch {
return false
}
}
/** 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()
@@ -76,7 +107,8 @@ async function establishSession(c: Context<App>, tokenResponse: Response) {
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 account = (await me.json()) as Record<string, unknown>
return c.json({ account: { ...account, isAdmin: isAdminToken(token.access_token) } })
}
const app = new Hono<App>()
@@ -94,33 +126,27 @@ const app = new Hono<App>()
// ---- 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)
// Manual web signups are disabled for now — accounts are created via the game /
// platform, not the website. Kept as an explicit closed endpoint (rather than
// removed) so a direct POST is refused too, not just hidden in the UI. To reopen,
// forward a platform-less `grant_type=create_account` to auth and start a session
// (see git history), and restore the SignupForm in the client.
.post('/api/signup', (c) => c.json({ error: 'Account creation is currently disabled.' }, 403))
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.
// Log in with a username + password, then start a session. The auth password grant
// resolves the account by `username` (case-insensitive) — web players sign in with
// their username, not the numeric account id.
.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 { username, password } = await c.req
.json<{ username?: string; password?: string }>()
.catch(() => ({}) as { username?: string; password?: string })
if (!username || !password) {
return c.json({ error: 'Username and password are required.' }, 400)
}
const res = await postForm(`${authBase(c.env)}/connect/token`, {
grant_type: 'password',
account_id: String(accountId),
username,
platform: WEB_PLATFORM,
password,
})
@@ -133,6 +159,24 @@ const app = new Hono<App>()
return c.json({ success: true })
})
// Public homepage slideshow. Proxies the api worker's (public) slideshow feed and
// projects each image to a full img.<domain> URL the browser can load directly, so
// the page JS never has to know the upstream hosts. No session required.
.get('/api/slideshow', async (c) => {
const res = await fetch(`${apiBase(c.env)}/api/images/v1/slideshow`)
if (!res.ok) return relay(c, res)
const data = (await res.json()) as {
Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }>
ValidTill?: string
}
const images = (data.Images ?? []).map((i) => ({
url: `${imgBase(c.env)}/${i.ImageName}`,
username: i.Username,
roomName: i.RoomName,
}))
return c.json({ images, validTill: data.ValidTill ?? null })
})
// Current session's self account (used to restore UI state on page load).
.get('/api/me', async (c) => {
const token = sessionToken(c)
@@ -146,7 +190,11 @@ const app = new Hono<App>()
deleteCookie(c, SESSION_COOKIE, { path: '/' })
return c.json({ error: 'session expired' }, 401)
}
return relay(c, res)
if (!res.ok) return relay(c, res)
// Augment the self account with whether this session may use admin controls,
// read from the token's role claim (see isAdminToken).
const account = (await res.json()) as Record<string, unknown>
return c.json({ ...account, isAdmin: isAdminToken(token) })
})
// Set the signed-in account's email.
@@ -179,6 +227,58 @@ const app = new Hono<App>()
return relay(c, res)
})
// Broadcast a ServerMaintenance countdown to every connected client. Forwards the
// session token to the notify worker, which enforces the admin-role gate — so a
// non-admin session is rejected upstream (403) even though www shows no button.
// The notification frame carries `Msg: { StartsInMinutes }`, matching the client's
// ServerMaintenance handler; the response mirrors the reference maintenance API.
.post('/api/maintenance', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { startsInMinutes } = await c.req
.json<{ startsInMinutes?: number }>()
.catch(() => ({}) as { startsInMinutes?: number })
const minutes = Number(startsInMinutes)
const startsIn = Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : 0
const res = await fetch(`${notifyBase(c.env)}/internal/broadcast`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
notificationType: SERVER_MAINTENANCE,
data: { StartsInMinutes: startsIn },
}),
})
if (!res.ok) return relay(c, res)
const result = (await res.json()) as { delivered?: number }
return c.json({ success: true, starts_in_minutes: startsIn, connections: result.delivered ?? 0 })
})
// Send a coach/system message to every online player. Like maintenance, this
// forwards the session token to notify, which enforces the admin-role gate.
.post('/api/coach-message', async (c) => {
const token = sessionToken(c)
if (!token) return c.json({ error: 'not signed in' }, 401)
const { messageContent } = await c.req
.json<{ messageContent?: string }>()
.catch(() => ({}) as { messageContent?: string })
const content = typeof messageContent === 'string' ? messageContent.trim() : ''
if (content === '') return c.json({ error: 'A message is required.' }, 400)
const res = await fetch(`${notifyBase(c.env)}/internal/coach-message-all`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ messageContent: content }),
})
if (!res.ok) return relay(c, res)
const result = (await res.json()) as { sent?: number }
return c.json({ success: true, sent: result.sent ?? 0 })
})
// ---- Static SPA ---------------------------------------------------------
// Everything else is served from the built client assets. With
// `not_found_handling: single-page-application`, unknown routes return