diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts index 945eb4a..45c5740 100644 --- a/apps/api/src/images-db.ts +++ b/apps/api/src/images-db.ts @@ -36,9 +36,25 @@ export const SCHEMA_DDL: string[] = [ `CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`, ] +/** + * Saved-image categories from the C# `SavedImageType` enum — the value of a stored + * image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives here in + * the image data layer so both the upload route and the slideshow query share one + * definition. + */ +export const SavedImageType = { + None: 0, + ShareCamera: 1, + OutfitThumbnail: 2, + RoomThumbnail: 3, + ProfileThumbnail: 4, + InventionThumbnail: 5, +} as const + /** A stored image record (the client-facing SavedImage shape). */ export interface SavedImage { Id: number + /** A {@link SavedImageType} value. */ Type: number Accessibility: number AccessibilityLocked: boolean @@ -275,11 +291,12 @@ async function getRoomNames(db: D1Database, ids: number[]): Promise() const images = results.map((r) => JSON.parse(r.data) as SavedImage) diff --git a/apps/api/src/routes/images.ts b/apps/api/src/routes/images.ts index 9362961..7abd401 100644 --- a/apps/api/src/routes/images.ts +++ b/apps/api/src/routes/images.ts @@ -8,22 +8,13 @@ import { getImagesByRoom, getPlayerFeed, getSlideshowImages, + SavedImageType, setImageCheer, } from '../images-db' import { authedId, unauthorized } from '../http' import type { App } from '../context' -/** Saved-image categories from the C# `SavedImageType` enum (`imgMeta.savedImageType`). */ -const SavedImageType = { - None: 0, - ShareCamera: 1, - OutfitThumbnail: 2, - RoomThumbnail: 3, - ProfileThumbnail: 4, - InventionThumbnail: 5, -} as const - /** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */ const typeFolder: Record = { [SavedImageType.None]: 'none', @@ -150,13 +141,12 @@ export const imageRoutes = new Hono({ strict: false }) return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take)) }) - // Global slideshow feed — the most recent publicly-listable images (Accessibility - // 0 or 1) across all rooms, newest first, each joined to its creator's username - // and room name. Auth-gated. Returns `{ Images, ValidTill }`, where ValidTill is a - // short (2-minute) cache hint the client refreshes against. + // Global slideshow feed — the most recent publicly-listable ShareCamera photos + // (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its + // creator's username and room name. Public (no auth): it only surfaces already-public + // images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`, + // where ValidTill is a short (2-minute) cache hint the client refreshes against. .get('/api/images/v1/slideshow', async (c) => { - const id = await authedId(c) - if (id === null) return unauthorized(c) const Images = await getSlideshowImages(c.env.DB) const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString() return c.json({ Images, ValidTill }) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 979773d..833c6b0 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1065,6 +1065,7 @@ describe('images', () => { test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => { const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]) const fd = new FormData() + fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' })) const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, { @@ -1074,8 +1075,9 @@ describe('images', () => { }) expect(res.status).toBe(200) const { ImageName } = (await res.json()) as { ImageName: string } + // Keyed by //. (the type folder mirrors the CDN layout). expect(ImageName).toMatch( - /^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/ + /^sharecamera\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/ ) // The object is in the shared bucket under that key. @@ -1093,10 +1095,7 @@ describe('images', () => { expect(meta.CheerCount).toBe(0) }) - test('GET /api/images/v1/slideshow is auth-gated and joins username + room name', async () => { - // No token → 401. - expect((await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)).status).toBe(401) - + test('GET /api/images/v1/slideshow is public and joins username + room name', async () => { // Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42. await env.DB.prepare('INSERT INTO image (data) VALUES (?1)') .bind( @@ -1118,9 +1117,8 @@ describe('images', () => { ) .run() - const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`, { - headers: await bearer(), - }) + // No token — the slideshow is public. + const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`) expect(res.status).toBe(200) const body = (await res.json()) as { Images: Array> @@ -1287,8 +1285,9 @@ describe('images', () => { }) expect(res.status).toBe(200) const { ImageName } = (await res.json()) as { ImageName: string } + // Type 4 → the `profile/` type folder, then /.. expect(ImageName).toMatch( - /^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/ + /^profile\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/ ) // The account row now points its profileImage at the uploaded key. diff --git a/apps/notify/README.md b/apps/notify/README.md index 6f10e87..be6819e 100644 --- a/apps/notify/README.md +++ b/apps/notify/README.md @@ -15,10 +15,13 @@ instance holds the shared hub state (one process across all connections). - `GET /hub/v1` (WebSocket upgrade) — the hub. Forwarded to the Durable Object. Non-upgrade requests get `426`. - `POST /internal/notify` — `{ playerId, notificationType, data? }`. Send to a - player's connections, queueing if they're offline. **Unauthenticated; internal - use only (TODO: protect).** + player's connections, queueing if they're offline. **Admin-gated**: requires a + Bearer token carrying an admin role (`developer`/`moderator`) in its `role` claim. - `POST /internal/broadcast` — `{ notificationType, data? }`. Send to every - connected client. + connected client. Same admin-role gate as `/internal/notify`. +- `POST /internal/coach-message-all` — `{ messageContent }`. Broadcast a coach/system + `MessageReceived` to every connected client (online-only; not persisted). Same + admin-role gate. ## Hub protocol diff --git a/apps/notify/src/notifications-hub.ts b/apps/notify/src/notifications-hub.ts index 4956716..533a2a4 100644 --- a/apps/notify/src/notifications-hub.ts +++ b/apps/notify/src/notifications-hub.ts @@ -1,5 +1,7 @@ import { DurableObject } from 'cloudflare:workers' +import { NotificationType } from './notification-types' + import type { Env } from './context' /** @@ -34,6 +36,12 @@ interface HubMessage { arguments?: unknown[] } +/** The Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll). */ +const COACH_PLAYER_ID = 1 + +/** The Message `Type` a coach/system message carries (a Message-model enum, not a NotificationType). */ +const COACH_MESSAGE_TYPE = 100 + export class NotificationsHub extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) @@ -227,7 +235,52 @@ export class NotificationsHub extends DurableObject { data?: Record ): Promise<{ delivered: number; queued: boolean }> { const payload = this.buildNotificationPayload(notificationType, data) + const delivered = this.deliverToPlayer(playerId, payload) + if (delivered === 0) { + this.ctx.storage.sql.exec( + 'INSERT INTO pending (playerId, payload) VALUES (?, ?)', + playerId, + payload + ) + return { delivered: 0, queued: true } + } + return { delivered, queued: false } + } + + /** + * Send a "coach" message to every connected client (mirrors the reference + * `SendCoachMessageAll`, using the hub's live connections as the online set): each + * handshaken socket gets a `MessageReceived` notification carrying a Message from + * the Coach account (player 1). Online-only — nothing is queued or persisted, and + * it's a broadcast, so the Message has no per-recipient `ToPlayerId`. Returns how + * many connected clients were messaged. + */ + async coachMessageAll(content: string): Promise<{ sent: number }> { + const payload = this.buildNotificationPayload(NotificationType.MessageReceived, { + FromPlayerId: COACH_PLAYER_ID, + Type: COACH_MESSAGE_TYPE, + Data: content, + }) + return { sent: this.broadcastToConnected(payload) } + } + + /** Broadcast a notification to every connected (handshaken) client. */ + async broadcast( + notificationType: string | number, + data?: Record + ): Promise<{ delivered: number }> { + return { delivered: this.broadcastToConnected(this.buildNotificationPayload(notificationType, data)) } + } + + // ---- Helpers ------------------------------------------------------------- + + /** + * Send an already-built `Notification` payload to every live socket of a player's + * subscribed connections; returns how many sockets received it (0 = offline). The + * shared send path for {@link notifyPlayer} and {@link coachMessageAll}. + */ + private deliverToPlayer(playerId: number, payload: string): number { const connectionIds = this.ctx.storage.sql .exec<{ connectionId: string }>( 'SELECT DISTINCT connectionId FROM subscriptions WHERE playerId = ?', @@ -243,24 +296,15 @@ export class NotificationsHub extends DurableObject { delivered++ } } - - if (delivered === 0) { - this.ctx.storage.sql.exec( - 'INSERT INTO pending (playerId, payload) VALUES (?, ?)', - playerId, - payload - ) - return { delivered: 0, queued: true } - } - return { delivered, queued: false } + return delivered } - /** Broadcast a notification to every connected (handshaken) client. */ - async broadcast( - notificationType: string | number, - data?: Record - ): Promise<{ delivered: number }> { - const payload = this.buildNotificationPayload(notificationType, data) + /** + * Send an already-built `Notification` payload to every connected (handshaken) + * socket; returns how many received it. Shared by {@link broadcast} and + * {@link coachMessageAll}. + */ + private broadcastToConnected(payload: string): number { let delivered = 0 for (const ws of this.ctx.getWebSockets()) { const state = ws.deserializeAttachment() as SocketState | null @@ -268,11 +312,9 @@ export class NotificationsHub extends DurableObject { ws.send(this.invocation('Notification', [payload])) delivered++ } - return { delivered } + return delivered } - // ---- Helpers ------------------------------------------------------------- - /** * Build the `Notification` argument: a JSON string `{ Id, Msg }` * (null values are dropped from `Msg`). `Id` is a client-defined tag — a diff --git a/apps/notify/src/notify.app.ts b/apps/notify/src/notify.app.ts index 41a133f..69c12a4 100644 --- a/apps/notify/src/notify.app.ts +++ b/apps/notify/src/notify.app.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { logger, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId } from '@repo/jwt' +import { validateAndGetRoles } from '@repo/jwt' import { NotificationsHub } from './notifications-hub' @@ -29,21 +29,23 @@ function isNotificationType(value: unknown): value is string | number { } /** - * Account ids allowed to call the internal send/broadcast endpoints. Temporary - * lockdown until these are properly gated — for now only these admins can push - * notifications through the shared hub. + * Roles allowed to call the internal send/broadcast endpoints. These are the + * operator-granted elevated roles (see the auth worker's `role` claim, set from an + * account's isDeveloper/isModerator flags via the admin CLI) — so a staffer grants + * themselves the role and can then push notifications through the shared hub, e.g. + * from the accounts web UI's maintenance control. */ -const ADMIN_ACCOUNT_IDS = new Set([1, 2]) +const ADMIN_ROLES = new Set(['developer', 'moderator']) /** - * Gates the `/internal/*` endpoints on a valid Bearer token whose `sub` is an - * allowed admin account. 401 for a missing/invalid token, 403 for a valid token - * that isn't an admin. + * Gates the `/internal/*` endpoints on a valid Bearer token that carries one of the + * {@link ADMIN_ROLES} in its `role` claim. 401 for a missing/invalid token, 403 for a + * valid token that lacks an admin role. */ const requireAdmin: MiddlewareHandler = async (c, next) => { - const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) - if (accountId === null) return c.json({ error: 'Unauthorized' }, 401) - if (!ADMIN_ACCOUNT_IDS.has(accountId)) return c.json({ error: 'Forbidden' }, 403) + const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get()) + if (roles === null) return c.json({ error: 'Unauthorized' }, 401) + if (!roles.some((role) => ADMIN_ROLES.has(role))) return c.json({ error: 'Forbidden' }, 403) await next() } @@ -122,5 +124,15 @@ const app = new Hono() return c.json({ success: true, ...result }) }) + // Send a coach/system direct message to every currently-online player. + .post('/internal/coach-message-all', async (c) => { + const body = await c.req.json<{ messageContent?: string }>().catch(() => null) + const content = typeof body?.messageContent === 'string' ? body.messageContent.trim() : '' + if (content === '') return c.json({ error: 'messageContent is required' }, 400) + const result = + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).coachMessageAll(content) + return c.json({ success: true, ...result }) + }) + export { NotificationsHub } export default app diff --git a/apps/notify/src/test/integration/api.test.ts b/apps/notify/src/test/integration/api.test.ts index 6a98fde..bf1995d 100644 --- a/apps/notify/src/test/integration/api.test.ts +++ b/apps/notify/src/test/integration/api.test.ts @@ -22,10 +22,12 @@ function b64url(input: ArrayBuffer | string): string { for (const byte of bytes) binary += String.fromCharCode(byte) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } -async function bearer(sub: string): Promise> { +async function bearer(sub: string, roles?: string[]): Promise> { const now = Math.floor(Date.now() / 1000) + const claims: Record = { sub, exp: now + 3600 } + if (roles) claims.role = roles const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( - JSON.stringify({ sub, exp: now + 3600 }) + JSON.stringify(claims) )}` const key = await crypto.subtle.importKey( 'raw', @@ -104,14 +106,14 @@ async function connect( const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS) -// The /internal/* endpoints are admin-gated, so default to an admin (account 1) -// Bearer token; pass `auth` to override (e.g. to test the 401/403 paths). +// The /internal/* endpoints are admin-gated (a token carrying an admin role), so +// default to a moderator token; pass `auth` to override (e.g. the 401/403 paths). const post = async (path: string, body: unknown, auth?: Record) => exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(auth ?? (await bearer('1'))), + ...(auth ?? (await bearer('1', ['gameClient', 'moderator']))), }, body: JSON.stringify(body), }) @@ -150,14 +152,18 @@ describe('internal endpoint auth', () => { expect(res.status).toBe(401) }) - test('403 for a valid token that is not an admin', async () => { - const res = await post('/internal/notify', body, await bearer('3')) + test('403 for a valid token without an admin role', async () => { + const res = await post('/internal/notify', body, await bearer('3', ['gameClient'])) expect(res.status).toBe(403) }) - test('admin accounts 1 and 2 are allowed', async () => { - for (const sub of ['1', '2']) { - const res = await post('/internal/broadcast', { notificationType: 1 }, await bearer(sub)) + test('tokens carrying an admin role are allowed', async () => { + for (const role of ['developer', 'moderator']) { + const res = await post( + '/internal/broadcast', + { notificationType: 1 }, + await bearer('3', ['gameClient', role]) + ) expect(res.status).toBe(200) } }) @@ -251,6 +257,35 @@ describe('notification delivery', () => { ws.close() }) + test('coach-message-all messages every connected client', async () => { + const a = await connect('coach-a') + const b = await connect('coach-b') + + const res = await post('/internal/coach-message-all', { messageContent: 'hello all' }) + expect(res.status).toBe(200) + expect(((await res.json()) as { sent: number }).sent).toBeGreaterThanOrEqual(2) + + const noteA = await a.waitFor((r) => r.type === 1 && r.target === 'Notification') + const payloadA = JSON.parse((noteA.arguments as string[])[0]) as { + Id: string + Msg: Record + } + expect(payloadA.Id).toBe('2') // MessageReceived + expect(payloadA.Msg).toMatchObject({ FromPlayerId: 1, Type: 100, Data: 'hello all' }) + + const noteB = await b.waitFor((r) => r.type === 1 && r.target === 'Notification') + const payloadB = JSON.parse((noteB.arguments as string[])[0]) as { Msg: Record } + expect(payloadB.Msg).toMatchObject({ Data: 'hello all' }) + + a.ws.close() + b.ws.close() + }) + + test('coach-message-all 400s on an empty message', async () => { + const res = await post('/internal/coach-message-all', { messageContent: ' ' }) + expect(res.status).toBe(400) + }) + test('emits a numeric notificationType as a string Id', async () => { // The client dispatches on a string Id, so numeric codes (e.g. econ's // NotificationType enum) must be serialized as strings or they're dropped. diff --git a/apps/www/index.html b/apps/www/index.html index 7d1649d..f81b56c 100644 --- a/apps/www/index.html +++ b/apps/www/index.html @@ -3,7 +3,7 @@ - Recflare Accounts + RecFlare
diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index fd38db1..d5d72dd 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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(path: string, body?: unknown): Promise { 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 ( + { + e.preventDefault() + navigate(to) + }} + > + {children} + + ) +} + export function App() { // undefined = still checking the session; null = signed out. const [account, setAccount] = useState(undefined) + const { path, navigate } = useRouter() useEffect(() => { - api<{ accountId: number } & SelfAccount>('/api/me') + api('/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 ( + <> + + {path === '/login' ? ( + + ) : path === '/account' ? ( + + ) : ( + + )} + + ) +} + +/** 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 ( +
+ + RecFlare + + +
+ ) +} + +/** Public homepage: a slideshow of recent public photos. */ +function HomePage() { + return ( +
+ +
+ ) +} + +/** 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(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

Couldn’t load the slideshow: {error}

+ if (!slides) return

Loading…

+ if (slides.length === 0) return

No photos yet.

+ + const slide = slides[idx] + const step = (delta: number) => setIdx((i) => (i + delta + slides.length) % slides.length) + + return ( +
+
+ {`Photo + {slides.length > 1 && ( + <> + + + + )} +
+
+
+ @{slide.username} + {slide.roomName && · {slide.roomName}} +
+
+ {idx + 1} / {slides.length} +
+
+
+ ) +} + +/** 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 (
-

Recflare Accounts

- {account === undefined ? ( -

Loading…

- ) : account ? ( - - ) : ( - - )} +
+

Sign in

+ { + onAuthed(a) + navigate('/account') + }} + /> +
+
+ ) +} + +/** 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 ( +
+

{account === undefined ? 'Loading…' : 'Redirecting…'}

+
+ ) + } + + return ( +
+

My account

+
) } @@ -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 ( -
-
- - -
- {tab === 'signup' ? : } -
- ) -} - -function SignupForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) { - const [password, setPassword] = useState('') - const { pending, error, run } = useAction() - - return ( -
{ - e.preventDefault() - void run(async () => { - const { account } = await api<{ account: SelfAccount }>('/api/signup', { password }) - onAuthed(account) - return '' - }) - }} - > -

- A new account id is assigned automatically. Choose a password to sign in later. -

- - {error &&

{error}

} - -
- ) -} - +// 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 }) { }} > @@ -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: () => }, + { id: 'password', label: 'Password', render: () => }, + ...(account.isAdmin + ? [ + { id: 'maintenance', label: 'Server maintenance', render: () => }, + { id: 'coach', label: 'Broadcast message', render: () => }, + ] + : []), + ] + const [active, setActive] = useState(sections[0].id) + const current = sections.find((s) => s.id === active) ?? sections[0] + return ( <>
-
-
-
Signed in as
-
- {account.displayName || account.username}{' '} - #{account.accountId} -
-
{account.email ?? 'no email set'}
-
- +
Signed in as
+
+ {account.displayName || account.username}{' '} + #{account.accountId}
+
@{account.username}
+
{account.email ?? 'no email set'}
- - +
+ +
{current.render()}
+
) } +/** Admin-only: send a coach/system message to every online player. */ +function CoachMessageForm() { + const [message, setMessage] = useState('') + const { pending, error, done, run } = useAction() + + return ( +
+

Broadcast message

+

+ Send a message from the Coach to every connected player. Players who aren't online + won't receive it. +

+
{ + 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'}.` + }) + }} + > +