diff --git a/.gitignore b/.gitignore index c7b20d6..9594d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage *.exe *.exe~ .env.local +.pg-creds.env package-lock.json client/build/ redis-unstable/ diff --git a/DEPLOY_RAILWAY.md b/DEPLOY_RAILWAY.md index ef66bac..4e72a99 100644 --- a/DEPLOY_RAILWAY.md +++ b/DEPLOY_RAILWAY.md @@ -121,6 +121,7 @@ MATCH_CLAIM_STORE_TTL_SECONDS=43200 ```env MATCHMAKING_TICKET_STORE_BACKEND=redis MATCHMAKING_TICKET_STORE_REDIS_URL=${{redis.REDIS_URL}} +MATCH_SERVICE_INTERNAL_URL=http://${{match-service.RAILWAY_PRIVATE_DOMAIN}}:${{match-service.PORT}} ``` ## First staging checklist @@ -151,3 +152,61 @@ Still recommended after first successful staging deploy: - add custom domains - add monitoring and alerting - add load testing before public launch + +## Rated beta launch checklist + +Use this checklist before calling the hosted `/play` flow launch-ready. + +1. Verify Railway service health: + - `web` loads its public domain + - `gateway` returns `200` from `/healthz` + - `match-service` returns `200` from `/healthz` + - `platform-service` returns `200` from `/healthz` + - `matchmaking-service` returns `200` from `/healthz` +2. Verify backend wiring from the web app: + - open `/status` + - confirm gateway, platform, match, and matchmaking all report healthy + - confirm `/api/gateway/bootstrap` returns healthy downstream state instead of localhost connection errors +3. Verify data backends: + - Railway Redis is attached to `matchmaking-service` and `platform-service` + - Railway Postgres is attached to `platform-service` and `match-service` +4. Run the hosted `/play` smoke test: + - signed-out player can join casual + - signed-out player sees `Sign in to join rated` + - signed-in player can join rated + - queued refresh restores queue state + - matched refresh restores the live match or shows `Return to Match` + - private invite link opens the canonical `/match/:id` route + - a fully occupied invite room resolves to spectate or room-full behavior + +## Beta data reset before launch + +If production beta data becomes noisy, clear it before the public rated beta push so recovery logic is restoring real player state instead of test leftovers. + +### Redis + +Reset stale queue and claim state: + +```text +chess404:matchmaking:tickets +chess404:platform:match-claims +``` + +### Postgres + +Review and clear test-only rows from: + +```sql +guests +finalized_matches +direct_challenges +``` + +Suggested approach: + +1. Remove stale queue tickets and stale match claims first. +2. Delete or archive test guest rows only if you intentionally want a clean guest ladder. +3. Delete abandoned direct challenges that were created for testing and no longer map to a live product scenario. +4. Redeploy the services after cleanup so fresh hosted sessions repopulate state from a known baseline. + +Do not clear production data blindly once real players are using the platform. Snapshot Postgres first, then remove only the stale beta-era rows you intend to reset. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 4e4b56e..8abfed9 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -256,14 +256,19 @@ Completed: - starting a fresh game now clears old room-bound URL state instead of accidentally reopening the same matched room again - backend now resolves authoritative seat ownership from stored guest ids, not only `white` / `black` placeholder player ids - main app now submits authoritative moves, cards, draw/chat/resign intents using the match seat guest ids when available +- `useMatchTimer` hook extracted from `useMatchEngine`: timer, clock, and abort countdown state are now managed in a dedicated reusable hook +- `useMatchReplay` hook extracted from `useMatchEngine`: board review navigation (`reviewIdx`, `reviewBoard`, `goToSnap`, `reviewFirst/Prev/Next/Last`, `isReviewing`) are now managed in a dedicated reusable hook +- `setup-postgres.ps1` added: one-shot script to create the `chess404` Postgres database and save local credentials to `.pg-creds.env` +- `start-local-postgres.ps1` added: full local stack startup with Postgres backends for platform-service (guests, accounts, archive) and match-service (archive); matchmaking stays on file backend until Redis is available Still missing: - replace the remaining local match authority with server snapshots/events - isolate animation state from gameplay state more cleanly -- split `apps/web/src/App.tsx` into smaller modules - shift focus from gameplay-card migration into platform work: persistence, matchmaking, auth, ratings, reconnects, and replay/history storage - support optimistic UI with rollback from authoritative backend events +- switch `MATCH_CLAIM_STORE_BACKEND` from `memory` to `redis` once a local Redis instance is available +- switch `MATCHMAKING_TICKET_STORE_BACKEND` from `file` to `redis` once a local Redis instance is available ### 5. Product Systems diff --git a/apps/web/app/ShellLayout.tsx b/apps/web/app/ShellLayout.tsx new file mode 100644 index 0000000..6444315 --- /dev/null +++ b/apps/web/app/ShellLayout.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import AppShell, { ShellNavGroup, ShellNavItem } from '../src/components/layout/AppShell'; +import { + AdminIcon, + CardsIcon, + CommunityIcon, + FriendsIcon, + HistoryIcon, + InboxIcon, + PlayIcon, + ProfileIcon, + StatusIcon, + WatchIcon, +} from '../src/components/layout/icons'; + +const primaryItems: ShellNavItem[] = [ + { key: '/play', label: 'Play', icon: }, + { key: '/watch', label: 'Watch', icon: }, +]; + +const utilityGroups: ShellNavGroup[] = [ + { + label: 'Social', + items: [ + { key: '/rankings', label: 'Rankings', icon: }, + { key: '/profiles', label: 'Profiles', icon: }, + { key: '/community', label: 'Community', icon: }, + ], + }, + { + label: 'Library', + items: [ + { key: '/cards', label: 'Card Index', icon: }, + { key: '/history', label: 'History', icon: }, + ], + }, +]; + +export function ShellLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const [accountOpen, setAccountOpen] = React.useState(false); + + // In a real implementation, pageMeta would be dynamic based on pathname + const pageMeta = { + title: pathname === '/' ? 'Home' : pathname.slice(1).charAt(0).toUpperCase() + pathname.slice(2), + }; + + const handleNavigate = (key: string) => { + router.push(key); + }; + + return ( + setAccountOpen(true)} + > + {children} + {/* + In full implementation, the Auth/Account modal would be rendered here + when accountOpen is true + */} + + ); +} diff --git a/apps/web/app/account/page.tsx b/apps/web/app/account/page.tsx new file mode 100644 index 0000000..389134c --- /dev/null +++ b/apps/web/app/account/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Account'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 0000000..1cb2f3f --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Admin'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/app/api/_lib/internal-service.ts b/apps/web/app/api/_lib/internal-service.ts new file mode 100644 index 0000000..09bb74c --- /dev/null +++ b/apps/web/app/api/_lib/internal-service.ts @@ -0,0 +1,149 @@ +interface InternalServiceProxyConfig { + explicitUrl?: string; + fallbackUrl: string; + envName: string; + serviceName: string; +} + +interface ResolvedInternalService { + baseUrl: string; + usedFallback: boolean; + warning?: string; +} + +export async function proxyInternalService(request: Request, path: string, config: InternalServiceProxyConfig): Promise { + const resolved = resolveInternalServiceBaseUrl(config); + const url = `${resolved.baseUrl}${path}`; + const init: RequestInit = { + method: request.method, + headers: filterHeaders(request.headers), + cache: 'no-store', + }; + + if (request.method !== 'GET' && request.method !== 'HEAD') { + init.body = await request.text(); + } + + try { + const upstream = await fetch(url, init); + const body = await upstream.text(); + const headers = filterResponseHeaders(upstream.headers); + if (resolved.warning) { + headers.set('x-chess404-proxy-warning', resolved.warning); + } + return new Response(body, { + status: upstream.status, + headers, + }); + } catch (error) { + return buildProxyFailureResponse(config, resolved, error); + } +} + +export async function proxyInternalServiceStream(request: Request, path: string, config: InternalServiceProxyConfig): Promise { + const resolved = resolveInternalServiceBaseUrl(config); + const url = `${resolved.baseUrl}${path}`; + const init: RequestInit = { + method: request.method, + headers: filterHeaders(request.headers), + cache: 'no-store', + }; + + if (request.method !== 'GET' && request.method !== 'HEAD') { + init.body = await request.text(); + } + + try { + const upstream = await fetch(url, init); + const headers = filterResponseHeaders(upstream.headers); + if (resolved.warning) { + headers.set('x-chess404-proxy-warning', resolved.warning); + } + return new Response(upstream.body, { + status: upstream.status, + headers, + }); + } catch (error) { + return buildProxyFailureResponse(config, resolved, error); + } +} + +function buildProxyFailureResponse( + config: InternalServiceProxyConfig, + resolved: ResolvedInternalService, + error: unknown, +): Response { + const detail = error instanceof Error ? error.message : 'unreachable upstream'; + const guidance = `${config.envName} must be a full internal URL with a port, for example ${config.fallbackUrl}.`; + const warning = resolved.warning ? `${resolved.warning}. ` : ''; + return Response.json({ + error: `${config.serviceName} is unreachable. ${warning}${guidance} Attempted ${resolved.baseUrl}. Upstream error: ${detail}`, + }, { status: 502 }); +} + +function resolveInternalServiceBaseUrl(config: InternalServiceProxyConfig): ResolvedInternalService { + const fallback = sanitizeBaseUrl(config.fallbackUrl) ?? config.fallbackUrl.trim().replace(/\/$/, ''); + const explicit = sanitizeBaseUrl(config.explicitUrl); + + if (!explicit) { + return { baseUrl: fallback, usedFallback: true }; + } + + try { + const parsed = new URL(explicit); + if (requiresPortFallback(parsed)) { + return { + baseUrl: fallback, + usedFallback: true, + warning: `${config.envName} omitted the internal service port`, + }; + } + return { baseUrl: parsed.toString().replace(/\/$/, ''), usedFallback: false }; + } catch { + return { + baseUrl: fallback, + usedFallback: true, + warning: `${config.envName} is not a valid URL`, + }; + } +} + +function sanitizeBaseUrl(value?: string): string | null { + const trimmed = value?.trim().replace(/\/$/, ''); + if (!trimmed || trimmed.includes('${{') || /:\s*$/.test(trimmed)) { + return null; + } + return trimmed; +} + +function requiresPortFallback(url: URL): boolean { + if (url.port) { + return false; + } + const hostname = url.hostname.toLowerCase(); + return hostname.endsWith('.railway.internal') || hostname === 'localhost' || hostname === '127.0.0.1'; +} + +function filterHeaders(headers: Headers): Headers { + const next = new Headers(); + headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === 'host' || lower === 'connection' || lower === 'content-length') { + return; + } + next.set(key, value); + }); + return next; +} + +function filterResponseHeaders(headers: Headers): Headers { + const next = new Headers(); + headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === 'content-length' || lower === 'connection' || lower === 'transfer-encoding') { + return; + } + next.set(key, value); + }); + return next; +} diff --git a/apps/web/app/api/gateway/_lib/proxy.ts b/apps/web/app/api/gateway/_lib/proxy.ts index 49d1d57..5176a91 100644 --- a/apps/web/app/api/gateway/_lib/proxy.ts +++ b/apps/web/app/api/gateway/_lib/proxy.ts @@ -1,57 +1,10 @@ -const backendBaseUrl = resolveBackendBaseUrl( - process.env.GATEWAY_INTERNAL_URL, - 'http://gateway.railway.internal:8080', -); +import { proxyInternalService } from '../../_lib/internal-service'; export async function proxyGateway(request: Request, path: string): Promise { - const url = `${backendBaseUrl}${path}`; - const init: RequestInit = { - method: request.method, - headers: filterHeaders(request.headers), - cache: 'no-store', - }; - - if (request.method !== 'GET' && request.method !== 'HEAD') { - init.body = await request.text(); - } - - const upstream = await fetch(url, init); - const body = await upstream.text(); - - return new Response(body, { - status: upstream.status, - headers: filterResponseHeaders(upstream.headers), - }); -} - -function filterHeaders(headers: Headers): Headers { - const next = new Headers(); - headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if (lower === 'host' || lower === 'connection' || lower === 'content-length') { - return; - } - next.set(key, value); + return proxyInternalService(request, path, { + explicitUrl: process.env.GATEWAY_INTERNAL_URL, + fallbackUrl: 'http://gateway.railway.internal:8080', + envName: 'GATEWAY_INTERNAL_URL', + serviceName: 'gateway', }); - return next; -} - -function filterResponseHeaders(headers: Headers): Headers { - const next = new Headers(); - headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if (lower === 'content-length' || lower === 'connection' || lower === 'transfer-encoding') { - return; - } - next.set(key, value); - }); - return next; -} - -function resolveBackendBaseUrl(explicit: string | undefined, fallback: string): string { - const value = explicit?.trim().replace(/\/$/, ''); - if (!value || value.includes('${{') || /:\s*$/.test(value)) { - return fallback; - } - return value; } diff --git a/apps/web/app/api/gateway/challenges/[challengeId]/accept/route.ts b/apps/web/app/api/gateway/challenges/[challengeId]/accept/route.ts new file mode 100644 index 0000000..406bafd --- /dev/null +++ b/apps/web/app/api/gateway/challenges/[challengeId]/accept/route.ts @@ -0,0 +1,11 @@ +import { proxyGateway } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + request: Request, + context: { params: Promise<{ challengeId: string }> } +): Promise { + const { challengeId } = await context.params; + return proxyGateway(request, `/api/challenges/${encodeURIComponent(challengeId)}/accept`); +} diff --git a/apps/web/app/api/gateway/challenges/route.ts b/apps/web/app/api/gateway/challenges/route.ts new file mode 100644 index 0000000..d4e67ca --- /dev/null +++ b/apps/web/app/api/gateway/challenges/route.ts @@ -0,0 +1,7 @@ +import { proxyGateway } from '../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyGateway(request, '/api/challenges'); +} diff --git a/apps/web/app/api/gateway/matches/[matchId]/presence/route.ts b/apps/web/app/api/gateway/matches/[matchId]/presence/route.ts new file mode 100644 index 0000000..2dff6c1 --- /dev/null +++ b/apps/web/app/api/gateway/matches/[matchId]/presence/route.ts @@ -0,0 +1,11 @@ +import { proxyGateway } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + request: Request, + context: { params: Promise<{ matchId: string }> }, +): Promise { + const { matchId } = await context.params; + return proxyGateway(request, `/api/matches/${matchId}/presence`); +} diff --git a/apps/web/app/api/gateway/private-matches/[matchId]/join/route.ts b/apps/web/app/api/gateway/private-matches/[matchId]/join/route.ts new file mode 100644 index 0000000..3e592aa --- /dev/null +++ b/apps/web/app/api/gateway/private-matches/[matchId]/join/route.ts @@ -0,0 +1,11 @@ +import { proxyGateway } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + request: Request, + context: { params: Promise<{ matchId: string }> } +): Promise { + const { matchId } = await context.params; + return proxyGateway(request, `/api/private-matches/${encodeURIComponent(matchId)}/join`); +} diff --git a/apps/web/app/api/gateway/private-matches/[matchId]/rematch/route.ts b/apps/web/app/api/gateway/private-matches/[matchId]/rematch/route.ts new file mode 100644 index 0000000..0526a91 --- /dev/null +++ b/apps/web/app/api/gateway/private-matches/[matchId]/rematch/route.ts @@ -0,0 +1,11 @@ +import { proxyGateway } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + request: Request, + context: { params: Promise<{ matchId: string }> } +): Promise { + const { matchId } = await context.params; + return proxyGateway(request, `/api/private-matches/${encodeURIComponent(matchId)}/rematch`); +} diff --git a/apps/web/app/api/gateway/private-matches/route.ts b/apps/web/app/api/gateway/private-matches/route.ts new file mode 100644 index 0000000..310c62c --- /dev/null +++ b/apps/web/app/api/gateway/private-matches/route.ts @@ -0,0 +1,7 @@ +import { proxyGateway } from '../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyGateway(request, '/api/private-matches'); +} diff --git a/apps/web/app/api/matchmaking/_lib/proxy.ts b/apps/web/app/api/matchmaking/_lib/proxy.ts index 19f6045..d6a3533 100644 --- a/apps/web/app/api/matchmaking/_lib/proxy.ts +++ b/apps/web/app/api/matchmaking/_lib/proxy.ts @@ -1,57 +1,10 @@ -const backendBaseUrl = resolveBackendBaseUrl( - process.env.MATCHMAKING_SERVICE_INTERNAL_URL, - 'http://matchmaking-service.railway.internal:8080', -); +import { proxyInternalService } from '../../_lib/internal-service'; export async function proxyMatchmaking(request: Request, path: string): Promise { - const url = `${backendBaseUrl}${path}`; - const init: RequestInit = { - method: request.method, - headers: filterHeaders(request.headers), - cache: 'no-store', - }; - - if (request.method !== 'GET' && request.method !== 'HEAD') { - init.body = await request.text(); - } - - const upstream = await fetch(url, init); - const body = await upstream.text(); - - return new Response(body, { - status: upstream.status, - headers: filterResponseHeaders(upstream.headers), - }); -} - -function filterHeaders(headers: Headers): Headers { - const next = new Headers(); - headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if (lower === 'host' || lower === 'connection' || lower === 'content-length') { - return; - } - next.set(key, value); + return proxyInternalService(request, path, { + explicitUrl: process.env.MATCHMAKING_SERVICE_INTERNAL_URL, + fallbackUrl: 'http://matchmaking-service.railway.internal:8080', + envName: 'MATCHMAKING_SERVICE_INTERNAL_URL', + serviceName: 'matchmaking-service', }); - return next; -} - -function filterResponseHeaders(headers: Headers): Headers { - const next = new Headers(); - headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if (lower === 'content-length' || lower === 'connection' || lower === 'transfer-encoding') { - return; - } - next.set(key, value); - }); - return next; -} - -function resolveBackendBaseUrl(explicit: string | undefined, fallback: string): string { - const value = explicit?.trim().replace(/\/$/, ''); - if (!value || value.includes('${{') || /:\s*$/.test(value)) { - return fallback; - } - return value; } diff --git a/apps/web/app/api/matchmaking/queues/snapshots/route.ts b/apps/web/app/api/matchmaking/queues/snapshots/route.ts new file mode 100644 index 0000000..90ed24d --- /dev/null +++ b/apps/web/app/api/matchmaking/queues/snapshots/route.ts @@ -0,0 +1,8 @@ +import { proxyMatchmaking } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request): Promise { + const { search } = new URL(request.url); + return proxyMatchmaking(request, `/api/queues/snapshots${search}`); +} diff --git a/apps/web/app/api/matchmaking/queues/tickets/route.ts b/apps/web/app/api/matchmaking/queues/tickets/route.ts index 4dac840..3341195 100644 --- a/apps/web/app/api/matchmaking/queues/tickets/route.ts +++ b/apps/web/app/api/matchmaking/queues/tickets/route.ts @@ -2,11 +2,189 @@ import { proxyMatchmaking } from '../../_lib/proxy'; export const dynamic = 'force-dynamic'; +const matchmakingBaseUrl = resolveBackendBaseUrl( + process.env.MATCHMAKING_SERVICE_INTERNAL_URL, + 'http://matchmaking-service.railway.internal:8080', +); + +const platformBaseUrl = resolveBackendBaseUrl( + process.env.PLATFORM_SERVICE_INTERNAL_URL, + 'http://platform-service.railway.internal:8080', +); + +interface QueueTicketCreatePayload { + guestId?: string; + queue?: 'casual' | 'rated'; + modeId?: string; + rating?: number; + displayName?: string; + accountId?: string; + accountSessionToken?: string; +} + +interface PlatformAccountSessionPayload { + account: { + accountId: string; + primaryGuestId: string; + linkedGuestIds?: string[]; + }; +} + export async function GET(request: Request): Promise { const { search } = new URL(request.url); return proxyMatchmaking(request, `/api/queues/tickets${search}`); } export async function POST(request: Request): Promise { - return proxyMatchmaking(request, '/api/queues/tickets'); + let payload: QueueTicketCreatePayload; + try { + payload = (await request.json()) as QueueTicketCreatePayload; + } catch { + return jsonError('invalid queue ticket payload', 400); + } + + const queue = payload.queue === 'rated' ? 'rated' : 'casual'; + const guestId = payload.guestId?.trim() ?? ''; + if (!guestId) { + return jsonError('guestId is required', 400); + } + + if (queue === 'rated') { + const accountId = payload.accountId?.trim() ?? ''; + const sessionToken = payload.accountSessionToken?.trim() ?? ''; + if (!accountId || !sessionToken) { + return jsonError('Rated queue requires a signed-in Chess404 account.', 401); + } + + const session = await validateRatedAccountSession(request, accountId, sessionToken); + if (session instanceof Response) { + return session; + } + + const linkedGuestIds = new Set([ + session.account.primaryGuestId, + ...(session.account.linkedGuestIds ?? []), + ].map(value => value.trim()).filter(Boolean)); + + if (!linkedGuestIds.has(guestId)) { + return jsonError('Rated queue must use a guest linked to the signed-in Chess404 account.', 403); + } + } + + return forwardMatchmaking(request, { + guestId, + accountId: payload.accountId?.trim() ?? undefined, + queue, + modeId: payload.modeId, + rating: payload.rating, + displayName: payload.displayName, + }); +} + +async function validateRatedAccountSession( + request: Request, + accountId: string, + sessionToken: string, +): Promise { + const upstream = await fetch(`${platformBaseUrl}/api/platform/account-sessions`, { + method: 'POST', + headers: ensureJSONHeaders(filterHeaders(request.headers)), + cache: 'no-store', + body: JSON.stringify({ accountId, sessionToken }), + }); + + const body = await upstream.text(); + if (!upstream.ok) { + const headers = filterResponseHeaders(upstream.headers); + const fallbackStatus = upstream.status === 403 ? 403 : 401; + const parsed = tryParseErrorPayload(body); + if (upstream.status === 403 && parsed?.restrictionKind) { + return new Response(body, { + status: upstream.status, + headers, + }); + } + return jsonError('Rated queue requires a signed-in Chess404 account.', fallbackStatus, headers); + } + + try { + return JSON.parse(body) as PlatformAccountSessionPayload; + } catch { + return jsonError('failed to validate rated account session', 502); + } +} + +async function forwardMatchmaking(request: Request, payload: QueueTicketCreatePayload): Promise { + const upstream = await fetch(`${matchmakingBaseUrl}/api/queues/tickets`, { + method: 'POST', + headers: ensureJSONHeaders(filterHeaders(request.headers)), + cache: 'no-store', + body: JSON.stringify(payload), + }); + + const body = await upstream.text(); + return new Response(body, { + status: upstream.status, + headers: filterResponseHeaders(upstream.headers), + }); +} + +function jsonError(message: string, status: number, headers?: Headers): Response { + const nextHeaders = headers ? new Headers(headers) : new Headers(); + nextHeaders.set('Content-Type', 'application/json'); + return new Response(JSON.stringify({ error: message }), { + status, + headers: nextHeaders, + }); +} + +function tryParseErrorPayload(body: string): { error?: string; restrictionKind?: string; restrictionReason?: string } | null { + try { + return JSON.parse(body) as { error?: string; restrictionKind?: string; restrictionReason?: string }; + } catch { + return null; + } +} + +function ensureJSONHeaders(headers: Headers): Headers { + const next = new Headers(headers); + if (!next.has('Content-Type')) { + next.set('Content-Type', 'application/json'); + } + if (!next.has('Accept')) { + next.set('Accept', 'application/json'); + } + return next; +} + +function filterHeaders(headers: Headers): Headers { + const next = new Headers(); + headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === 'host' || lower === 'connection' || lower === 'content-length') { + return; + } + next.set(key, value); + }); + return next; +} + +function filterResponseHeaders(headers: Headers): Headers { + const next = new Headers(); + headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === 'content-length' || lower === 'connection' || lower === 'transfer-encoding') { + return; + } + next.set(key, value); + }); + return next; +} + +function resolveBackendBaseUrl(explicit: string | undefined, fallback: string): string { + const value = explicit?.trim().replace(/\/$/, ''); + if (!value || value.includes('${{') || /:\s*$/.test(value)) { + return fallback; + } + return value; } diff --git a/apps/web/app/api/platform/_lib/proxy.ts b/apps/web/app/api/platform/_lib/proxy.ts index ad9d23e..2216405 100644 --- a/apps/web/app/api/platform/_lib/proxy.ts +++ b/apps/web/app/api/platform/_lib/proxy.ts @@ -1,57 +1,19 @@ -const backendBaseUrl = resolveBackendBaseUrl( - process.env.PLATFORM_SERVICE_INTERNAL_URL, - 'http://platform-service.railway.internal:8080', -); +import { proxyInternalService, proxyInternalServiceStream } from '../../_lib/internal-service'; export async function proxyPlatform(request: Request, path: string): Promise { - const url = `${backendBaseUrl}${path}`; - const init: RequestInit = { - method: request.method, - headers: filterHeaders(request.headers), - cache: 'no-store', - }; - - if (request.method !== 'GET' && request.method !== 'HEAD') { - init.body = await request.text(); - } - - const upstream = await fetch(url, init); - const body = await upstream.text(); - - return new Response(body, { - status: upstream.status, - headers: filterResponseHeaders(upstream.headers), - }); -} - -function filterHeaders(headers: Headers): Headers { - const next = new Headers(); - headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if (lower === 'host' || lower === 'connection' || lower === 'content-length') { - return; - } - next.set(key, value); + return proxyInternalService(request, path, { + explicitUrl: process.env.PLATFORM_SERVICE_INTERNAL_URL, + fallbackUrl: 'http://platform-service.railway.internal:8080', + envName: 'PLATFORM_SERVICE_INTERNAL_URL', + serviceName: 'platform-service', }); - return next; } -function filterResponseHeaders(headers: Headers): Headers { - const next = new Headers(); - headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if (lower === 'content-length' || lower === 'connection' || lower === 'transfer-encoding') { - return; - } - next.set(key, value); +export async function proxyPlatformStream(request: Request, path: string): Promise { + return proxyInternalServiceStream(request, path, { + explicitUrl: process.env.PLATFORM_SERVICE_INTERNAL_URL, + fallbackUrl: 'http://platform-service.railway.internal:8080', + envName: 'PLATFORM_SERVICE_INTERNAL_URL', + serviceName: 'platform-service', }); - return next; -} - -function resolveBackendBaseUrl(explicit: string | undefined, fallback: string): string { - const value = explicit?.trim().replace(/\/$/, ''); - if (!value || value.includes('${{') || /:\s*$/.test(value)) { - return fallback; - } - return value; } diff --git a/apps/web/app/api/platform/account-auth/credentials/route.ts b/apps/web/app/api/platform/account-auth/credentials/route.ts new file mode 100644 index 0000000..accb5da --- /dev/null +++ b/apps/web/app/api/platform/account-auth/credentials/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-auth/credentials'); +} diff --git a/apps/web/app/api/platform/account-auth/email-verification/confirm/route.ts b/apps/web/app/api/platform/account-auth/email-verification/confirm/route.ts new file mode 100644 index 0000000..595a9f3 --- /dev/null +++ b/apps/web/app/api/platform/account-auth/email-verification/confirm/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-auth/email-verification/confirm'); +} diff --git a/apps/web/app/api/platform/account-auth/email-verification/request/route.ts b/apps/web/app/api/platform/account-auth/email-verification/request/route.ts new file mode 100644 index 0000000..f54028a --- /dev/null +++ b/apps/web/app/api/platform/account-auth/email-verification/request/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-auth/email-verification/request'); +} diff --git a/apps/web/app/api/platform/account-auth/login/route.ts b/apps/web/app/api/platform/account-auth/login/route.ts new file mode 100644 index 0000000..4a80814 --- /dev/null +++ b/apps/web/app/api/platform/account-auth/login/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-auth/login'); +} diff --git a/apps/web/app/api/platform/account-auth/logout/route.ts b/apps/web/app/api/platform/account-auth/logout/route.ts new file mode 100644 index 0000000..330ddb8 --- /dev/null +++ b/apps/web/app/api/platform/account-auth/logout/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-auth/logout'); +} diff --git a/apps/web/app/api/platform/account-auth/overview/route.ts b/apps/web/app/api/platform/account-auth/overview/route.ts new file mode 100644 index 0000000..2493797 --- /dev/null +++ b/apps/web/app/api/platform/account-auth/overview/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-auth/overview'); +} diff --git a/apps/web/app/api/platform/account-auth/password-reset/confirm/route.ts b/apps/web/app/api/platform/account-auth/password-reset/confirm/route.ts new file mode 100644 index 0000000..5603050 --- /dev/null +++ b/apps/web/app/api/platform/account-auth/password-reset/confirm/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-auth/password-reset/confirm'); +} diff --git a/apps/web/app/api/platform/account-auth/password-reset/request/route.ts b/apps/web/app/api/platform/account-auth/password-reset/request/route.ts new file mode 100644 index 0000000..3858529 --- /dev/null +++ b/apps/web/app/api/platform/account-auth/password-reset/request/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-auth/password-reset/request'); +} diff --git a/apps/web/app/api/platform/account-auth/register/route.ts b/apps/web/app/api/platform/account-auth/register/route.ts new file mode 100644 index 0000000..702d431 --- /dev/null +++ b/apps/web/app/api/platform/account-auth/register/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-auth/register'); +} diff --git a/apps/web/app/api/platform/account-presence/route.ts b/apps/web/app/api/platform/account-presence/route.ts new file mode 100644 index 0000000..317e96e --- /dev/null +++ b/apps/web/app/api/platform/account-presence/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-presence'); +} diff --git a/apps/web/app/api/platform/account-security/overview/route.ts b/apps/web/app/api/platform/account-security/overview/route.ts new file mode 100644 index 0000000..493142a --- /dev/null +++ b/apps/web/app/api/platform/account-security/overview/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request) { + return proxyPlatform(request, '/api/platform/account-security/overview'); +} diff --git a/apps/web/app/api/platform/account-sessions/overview/route.ts b/apps/web/app/api/platform/account-sessions/overview/route.ts new file mode 100644 index 0000000..34a7c22 --- /dev/null +++ b/apps/web/app/api/platform/account-sessions/overview/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-sessions/overview'); +} diff --git a/apps/web/app/api/platform/account-sessions/revoke-others/route.ts b/apps/web/app/api/platform/account-sessions/revoke-others/route.ts new file mode 100644 index 0000000..f008a86 --- /dev/null +++ b/apps/web/app/api/platform/account-sessions/revoke-others/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-sessions/revoke-others'); +} diff --git a/apps/web/app/api/platform/account-sessions/revoke/route.ts b/apps/web/app/api/platform/account-sessions/revoke/route.ts new file mode 100644 index 0000000..0aa5b1d --- /dev/null +++ b/apps/web/app/api/platform/account-sessions/revoke/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/account-sessions/revoke'); +} diff --git a/apps/web/app/api/platform/accounts/by-handle/[handle]/route.ts b/apps/web/app/api/platform/accounts/by-handle/[handle]/route.ts new file mode 100644 index 0000000..7491d03 --- /dev/null +++ b/apps/web/app/api/platform/accounts/by-handle/[handle]/route.ts @@ -0,0 +1,12 @@ +import { proxyPlatform } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: Request, + context: { params: Promise<{ handle: string }> }, +): Promise { + const { handle } = await context.params; + const { search } = new URL(request.url); + return proxyPlatform(request, `/api/platform/accounts/by-handle/${encodeURIComponent(handle)}${search}`); +} diff --git a/apps/web/app/api/platform/challenges/[challengeId]/cancel/route.ts b/apps/web/app/api/platform/challenges/[challengeId]/cancel/route.ts new file mode 100644 index 0000000..5fdbac4 --- /dev/null +++ b/apps/web/app/api/platform/challenges/[challengeId]/cancel/route.ts @@ -0,0 +1,11 @@ +import { proxyPlatform } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + request: Request, + context: { params: Promise<{ challengeId: string }> } +): Promise { + const { challengeId } = await context.params; + return proxyPlatform(request, `/api/platform/challenges/${encodeURIComponent(challengeId)}/cancel`); +} diff --git a/apps/web/app/api/platform/challenges/[challengeId]/respond/route.ts b/apps/web/app/api/platform/challenges/[challengeId]/respond/route.ts new file mode 100644 index 0000000..54d6222 --- /dev/null +++ b/apps/web/app/api/platform/challenges/[challengeId]/respond/route.ts @@ -0,0 +1,11 @@ +import { proxyPlatform } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + request: Request, + context: { params: Promise<{ challengeId: string }> } +): Promise { + const { challengeId } = await context.params; + return proxyPlatform(request, `/api/platform/challenges/${encodeURIComponent(challengeId)}/respond`); +} diff --git a/apps/web/app/api/platform/challenges/overview/route.ts b/apps/web/app/api/platform/challenges/overview/route.ts new file mode 100644 index 0000000..f1ab1d7 --- /dev/null +++ b/apps/web/app/api/platform/challenges/overview/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/challenges/overview'); +} diff --git a/apps/web/app/api/platform/email-outbox/overview/route.ts b/apps/web/app/api/platform/email-outbox/overview/route.ts new file mode 100644 index 0000000..25a4183 --- /dev/null +++ b/apps/web/app/api/platform/email-outbox/overview/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request) { + return proxyPlatform(request, '/api/platform/email-outbox/overview'); +} diff --git a/apps/web/app/api/platform/friends/overview/route.ts b/apps/web/app/api/platform/friends/overview/route.ts new file mode 100644 index 0000000..59e75c0 --- /dev/null +++ b/apps/web/app/api/platform/friends/overview/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/friends/overview'); +} diff --git a/apps/web/app/api/platform/friends/remove/route.ts b/apps/web/app/api/platform/friends/remove/route.ts new file mode 100644 index 0000000..5c35652 --- /dev/null +++ b/apps/web/app/api/platform/friends/remove/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/friends/remove'); +} diff --git a/apps/web/app/api/platform/friends/requests/[requestId]/respond/route.ts b/apps/web/app/api/platform/friends/requests/[requestId]/respond/route.ts new file mode 100644 index 0000000..5b88d94 --- /dev/null +++ b/apps/web/app/api/platform/friends/requests/[requestId]/respond/route.ts @@ -0,0 +1,11 @@ +import { proxyPlatform } from '../../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + request: Request, + context: { params: Promise<{ requestId: string }> }, +): Promise { + const params = await context.params; + return proxyPlatform(request, `/api/platform/friends/requests/${encodeURIComponent(params.requestId)}/respond`); +} diff --git a/apps/web/app/api/platform/friends/requests/route.ts b/apps/web/app/api/platform/friends/requests/route.ts new file mode 100644 index 0000000..6bbadda --- /dev/null +++ b/apps/web/app/api/platform/friends/requests/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/friends/requests'); +} diff --git a/apps/web/app/api/platform/inbox/overview/route.ts b/apps/web/app/api/platform/inbox/overview/route.ts new file mode 100644 index 0000000..38857b1 --- /dev/null +++ b/apps/web/app/api/platform/inbox/overview/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/inbox/overview'); +} diff --git a/apps/web/app/api/platform/inbox/read-all/route.ts b/apps/web/app/api/platform/inbox/read-all/route.ts new file mode 100644 index 0000000..e711539 --- /dev/null +++ b/apps/web/app/api/platform/inbox/read-all/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/inbox/read-all'); +} diff --git a/apps/web/app/api/platform/inbox/read/route.ts b/apps/web/app/api/platform/inbox/read/route.ts new file mode 100644 index 0000000..a684d44 --- /dev/null +++ b/apps/web/app/api/platform/inbox/read/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/inbox/read'); +} diff --git a/apps/web/app/api/platform/inbox/stream/route.ts b/apps/web/app/api/platform/inbox/stream/route.ts new file mode 100644 index 0000000..97a71f7 --- /dev/null +++ b/apps/web/app/api/platform/inbox/stream/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatformStream } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatformStream(request, '/api/platform/inbox/stream'); +} diff --git a/apps/web/app/api/platform/moderation/admin/overview/route.ts b/apps/web/app/api/platform/moderation/admin/overview/route.ts new file mode 100644 index 0000000..cd5b04f --- /dev/null +++ b/apps/web/app/api/platform/moderation/admin/overview/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/moderation/admin/overview'); +} diff --git a/apps/web/app/api/platform/moderation/admin/reports/resolve/route.ts b/apps/web/app/api/platform/moderation/admin/reports/resolve/route.ts new file mode 100644 index 0000000..3318f04 --- /dev/null +++ b/apps/web/app/api/platform/moderation/admin/reports/resolve/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/moderation/admin/reports/resolve'); +} diff --git a/apps/web/app/api/platform/moderation/blocks/remove/route.ts b/apps/web/app/api/platform/moderation/blocks/remove/route.ts new file mode 100644 index 0000000..bcb62dc --- /dev/null +++ b/apps/web/app/api/platform/moderation/blocks/remove/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/moderation/blocks/remove'); +} diff --git a/apps/web/app/api/platform/moderation/blocks/route.ts b/apps/web/app/api/platform/moderation/blocks/route.ts new file mode 100644 index 0000000..bcb6138 --- /dev/null +++ b/apps/web/app/api/platform/moderation/blocks/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/moderation/blocks'); +} diff --git a/apps/web/app/api/platform/moderation/overview/route.ts b/apps/web/app/api/platform/moderation/overview/route.ts new file mode 100644 index 0000000..72af113 --- /dev/null +++ b/apps/web/app/api/platform/moderation/overview/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/moderation/overview'); +} diff --git a/apps/web/app/api/platform/moderation/reports/route.ts b/apps/web/app/api/platform/moderation/reports/route.ts new file mode 100644 index 0000000..ad06d6e --- /dev/null +++ b/apps/web/app/api/platform/moderation/reports/route.ts @@ -0,0 +1,7 @@ +import { proxyPlatform } from '../../_lib/proxy'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + return proxyPlatform(request, '/api/platform/moderation/reports'); +} diff --git a/apps/web/app/cards/page.tsx b/apps/web/app/cards/page.tsx new file mode 100644 index 0000000..7a15bb4 --- /dev/null +++ b/apps/web/app/cards/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Cards'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/app/community/page.tsx b/apps/web/app/community/page.tsx new file mode 100644 index 0000000..fea600c --- /dev/null +++ b/apps/web/app/community/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Community'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/app/friends/page.tsx b/apps/web/app/friends/page.tsx new file mode 100644 index 0000000..cc36986 --- /dev/null +++ b/apps/web/app/friends/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Friends'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index ec2585e..ae45616 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,3 +1,48 @@ +@import '../src/styles/design-tokens.css'; +@import '../src/styles/components.css'; +@import '../src/styles/layout.css'; + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; +} + +body { + font-family: var(--font-body); + background: var(--bg-base); + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; +} + +img, +svg { + display: block; + max-width: 100%; +} + +code, +pre { + font-family: var(--font-mono); +} + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', @@ -11,3 +56,98 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } + +/* ── Modern Responsive Match Layout ── */ +.match-layout { + display: flex; + align-items: stretch; + flex: 1; + padding: 12px 28px; + gap: 20px; + overflow: hidden; + min-height: 0; + width: 100%; +} + +.match-layout__left { + width: 290px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 0; +} + +.match-layout__center { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 0; + max-width: 80vh; + margin: 0 auto; +} + +.match-layout__board-wrapper { + width: 100%; + aspect-ratio: 1; + max-width: 100%; + max-height: 100%; + position: relative; + display: flex; + justify-content: center; + align-items: center; +} + +.match-layout__right { + width: 340px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 0; +} + +.match-layout__controls { + margin-top: 10px; +} + +/* Tablet Layout */ +@media (max-width: 1100px) { + .match-layout { + flex-direction: column; + overflow-y: auto; + padding: 16px; + align-items: center; + } + + .match-layout__left { + width: 100%; + max-width: 600px; + order: 2; + flex-direction: row; + flex-wrap: wrap; + } + + .match-layout__center { + width: 100%; + max-width: 600px; + order: 1; + flex: none; + } + + .match-layout__right { + width: 100%; + max-width: 600px; + order: 3; + } +} + +/* Mobile Layout */ +@media (max-width: 600px) { + .match-layout { + padding: 10px; + } +} diff --git a/apps/web/app/history/HistoryRouteClient.tsx b/apps/web/app/history/HistoryRouteClient.tsx new file mode 100644 index 0000000..53de7c8 --- /dev/null +++ b/apps/web/app/history/HistoryRouteClient.tsx @@ -0,0 +1,30 @@ +'use client'; + +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +interface HistoryRouteClientProps { + replayMatchId: string | null; + guestId: string | null; +} + +export default function HistoryRouteClient({ + replayMatchId, + guestId, +}: HistoryRouteClientProps) { + const platform = usePlatform(); + + React.useLayoutEffect(() => { + platform.setHistoryFocusMatchId(replayMatchId); + platform.setHistoryFocusGuestId(guestId); + platform.setActivePage('History'); + }, [ + guestId, + platform.setActivePage, + platform.setHistoryFocusGuestId, + platform.setHistoryFocusMatchId, + replayMatchId, + ]); + + return null; +} diff --git a/apps/web/app/history/page.tsx b/apps/web/app/history/page.tsx new file mode 100644 index 0000000..5e6661b --- /dev/null +++ b/apps/web/app/history/page.tsx @@ -0,0 +1,24 @@ +import HistoryRouteClient from './HistoryRouteClient'; + +function firstParam(value: string | string[] | undefined): string | null { + if (Array.isArray(value)) { + const first = value[0]?.trim(); + return first ? first : null; + } + const normalized = value?.trim(); + return normalized ? normalized : null; +} + +export default async function HistoryPageRoute({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + return ( + + ); +} diff --git a/apps/web/app/inbox/page.tsx b/apps/web/app/inbox/page.tsx new file mode 100644 index 0000000..a673054 --- /dev/null +++ b/apps/web/app/inbox/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Inbox'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 30b1e6e..2662c11 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,15 +1,51 @@ import './globals.css'; import type { Metadata } from 'next'; +import { Inter, JetBrains_Mono } from 'next/font/google'; + +const inter = Inter({ + subsets: ['latin'], + variable: '--font-sans', + weight: ['400', '500', '600', '700', '800'], + display: 'swap', +}); + +const jetBrainsMono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-mono', + weight: ['500', '700'], + display: 'swap', +}); export const metadata: Metadata = { title: 'Chess404', - description: 'Card-powered competitive chess moving toward a server-authoritative architecture.' + description: 'Chess404 is competitive online chess with curated card powers.' }; +import App from '../src/App'; + +function resolveMatchServiceHttpBase(): string { + return (process.env.NEXT_PUBLIC_MATCH_SERVICE_HTTP_BASE ?? process.env.NEXT_PUBLIC_MATCH_SERVICE_URL ?? '/api/realtime').replace(/\/$/, ''); +} + +function resolveMatchServiceWsBase(): string { + const explicit = (process.env.NEXT_PUBLIC_MATCH_SERVICE_WS_URL ?? process.env.NEXT_PUBLIC_MATCH_SERVICE_URL ?? '').replace(/\/$/, ''); + if (explicit) return explicit; + const httpBase = (process.env.NEXT_PUBLIC_MATCH_SERVICE_HTTP_BASE ?? '').replace(/\/$/, ''); + if (httpBase.endsWith('/api')) return httpBase.slice(0, -4); + return httpBase; +} + export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( - - {children} + + + + {children} + + ); } diff --git a/apps/web/app/match/[id]/page.tsx b/apps/web/app/match/[id]/page.tsx new file mode 100644 index 0000000..36ac164 --- /dev/null +++ b/apps/web/app/match/[id]/page.tsx @@ -0,0 +1,27 @@ +'use client'; + +import React from 'react'; +import { usePlatform } from '../../../src/contexts/PlatformContext'; + +export default function MatchRoute({ params }: { params: Promise<{ id: string }> }) { + const platform = usePlatform(); + const resolvedParams = React.use(params); + const id = resolvedParams.id; + const prevIdRef = React.useRef(null); + + React.useEffect(() => { + if (!id) return; + // Avoid re-processing the same match ID + if (prevIdRef.current === id) return; + prevIdRef.current = id; + + platform.requestedMatchIdRef.current = id; + if (!platform.authoritativeMatchId) { + // If not already in a match, force the ID change so App.tsx loads it + platform.setAuthoritativeMatchId(id); + } + platform.setActivePage('Match'); + }, [id, platform.authoritativeMatchId, platform.requestedMatchIdRef, platform.setActivePage, platform.setAuthoritativeMatchId]); + + return null; +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index aa5a639..22f6fef 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,29 +1,59 @@ -import App from '../src/App'; +import { redirect } from 'next/navigation'; -function resolveMatchServiceHttpBase(): string { - return (process.env.NEXT_PUBLIC_MATCH_SERVICE_HTTP_BASE ?? process.env.NEXT_PUBLIC_MATCH_SERVICE_URL ?? '/api/realtime').replace(/\/$/, ''); +function firstParam(value: string | string[] | undefined): string { + if (Array.isArray(value)) { + return value[0]?.trim() ?? ''; + } + return value?.trim() ?? ''; } -function resolveMatchServiceWsBase(): string { - const explicit = (process.env.NEXT_PUBLIC_MATCH_SERVICE_WS_URL ?? process.env.NEXT_PUBLIC_MATCH_SERVICE_URL ?? '').replace(/\/$/, ''); - if (explicit) { - return explicit; +export default async function HomePage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const params = await searchParams; + const requestedMatchId = firstParam(params.match); + const requestedReplayMatchId = firstParam(params.replay); + const requestedGuestId = firstParam(params.guest); + const requestedProfileHandle = firstParam(params.profile).toLowerCase(); + const requestedAuthAction = firstParam(params.auth); + const requestedAuthToken = firstParam(params.token); + const requestedAccountId = firstParam(params.account); + + if (requestedMatchId) { + redirect(`/match/${encodeURIComponent(requestedMatchId)}`); } - const httpBase = (process.env.NEXT_PUBLIC_MATCH_SERVICE_HTTP_BASE ?? '').replace(/\/$/, ''); - if (httpBase.endsWith('/api')) { - return httpBase.slice(0, -4); + if (requestedReplayMatchId || requestedGuestId) { + const historyParams = new URLSearchParams(); + if (requestedReplayMatchId) { + historyParams.set('replay', requestedReplayMatchId); + } + if (requestedGuestId) { + historyParams.set('guest', requestedGuestId); + } + redirect(`/history${historyParams.size ? `?${historyParams.toString()}` : ''}`); + } + + if (requestedProfileHandle) { + const profileParams = new URLSearchParams({ profile: requestedProfileHandle }); + redirect(`/profiles?${profileParams.toString()}`); + } + + if ( + (requestedAuthAction === 'verify-email' || requestedAuthAction === 'reset-password') && + requestedAuthToken + ) { + const accountParams = new URLSearchParams({ + auth: requestedAuthAction, + token: requestedAuthToken, + }); + if (requestedAccountId) { + accountParams.set('account', requestedAccountId); + } + redirect(`/account?${accountParams.toString()}`); } - return httpBase; -} -export default function HomePage() { - return ( - - ); + redirect('/play'); } diff --git a/apps/web/app/play/page.tsx b/apps/web/app/play/page.tsx new file mode 100644 index 0000000..8383061 --- /dev/null +++ b/apps/web/app/play/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import React from 'react'; +import PlayHubPage from '../../src/PlayHubPage'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function PlayRoute() { + const platform = usePlatform(); + + React.useEffect(() => { + platform.setActivePage('Play'); + }, [platform.setActivePage]); + + return null; +} + diff --git a/apps/web/app/profiles/page.tsx b/apps/web/app/profiles/page.tsx new file mode 100644 index 0000000..618f056 --- /dev/null +++ b/apps/web/app/profiles/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Profiles'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/app/rankings/page.tsx b/apps/web/app/rankings/page.tsx new file mode 100644 index 0000000..704a43b --- /dev/null +++ b/apps/web/app/rankings/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Rankings'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/app/status/page.tsx b/apps/web/app/status/page.tsx new file mode 100644 index 0000000..9bb27d0 --- /dev/null +++ b/apps/web/app/status/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Status'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/app/watch/page.tsx b/apps/web/app/watch/page.tsx new file mode 100644 index 0000000..6e2f7d6 --- /dev/null +++ b/apps/web/app/watch/page.tsx @@ -0,0 +1,12 @@ +'use client'; +import React from 'react'; +import { usePlatform } from '../../src/contexts/PlatformContext'; + +export default function Route() { + const platform = usePlatform(); + React.useEffect(() => { + platform.setActivePage('Watch'); + }, [platform.setActivePage]); + return null; +} + diff --git a/apps/web/diagnose_jsx.py b/apps/web/diagnose_jsx.py new file mode 100644 index 0000000..fe3ddf5 --- /dev/null +++ b/apps/web/diagnose_jsx.py @@ -0,0 +1,99 @@ +""" +Targeted fix: The board surface outer
wrapping everything is missing its closing tag. +Structure should be: + ) : showBoardSurface ? ( +
<- opens here (line 6187) + {/* Left col */} +
...
+ {/* Board col */} +
...
+ {/* Right panel */} +
+ + {/* ELO Stakes */} ... + {/* Game Controls */} ... +
+
<- needs to close here + ) : null} +""" + +with open('src/App.tsx', 'r', encoding='utf-8') as f: + text = f.read() + +# The problem: the } that closes the showBoardSurface ternary reads: +# {/* ELO Stakes */} +# ... +# ) : null} +# But there are several unclosed divs because the right-panel closing div +# from the old code was removed by the GamePanel patch. +# +# The new right panel structure (from GamePanel injection) ends with: +# /> +# {/* ELO Stakes */} +# (no closing
for the right panel wrapper div) +# Then immediately: ) : null} +# +# Fix: after the GamePanel closing /> and ELO Stakes block, ensure we have: +# <- closes the right panel column div +# <- closes the outer showBoardSurface flex div +# before ) : null} + +# Step 1: Find the right panel div opening +# It opens with:
", rp_div_start) + 1 + print(f"Right panel div: {repr(text[rp_div_start:rp_div_end][:80])}") +else: + print("WARNING: Could not find right panel comment") + +# Step 2: Find the end of the showBoardSurface block +# It ends with ) : null} somewhere before +BS_END = " ) : null}\n " +bs_pos = text.find(BS_END) +if bs_pos == -1: + # Try alternate indentation + BS_END = " ) : null}\n " + bs_pos = text.rfind(") : null}") + if bs_pos != -1: + # Find the actual ) : null} before + appshell_close = text.find("", bs_pos) + print(f"Found ) : null}} at char {bs_pos}, at char {appshell_close}") + bs_end_line = text[max(0, bs_pos-100):bs_pos+20] + print(f"Context: {repr(bs_end_line)}") + +# Step 3: The actual fix - find the GamePanel closing /> followed by ELO Stakes +# and ensure there are proper closing divs before ) : null} + +GAMEPANEL_END_MARKER = " />\n {/* ELO Stakes */}" +gp_end = text.find(GAMEPANEL_END_MARKER) +if gp_end == -1: + # Try with different spacing + GAMEPANEL_END_MARKER = "/>\n {/* ELO Stakes */}" + gp_end = text.rfind(GAMEPANEL_END_MARKER) + +if gp_end != -1: + print(f"Found GamePanel end marker at char {gp_end}") + # Show context around it + ctx = text[gp_end-100:gp_end+200] + print(f"Context: {repr(ctx[:300])}") +else: + print("WARNING: Could not find GamePanel end marker") + +# Step 4: Find what comes between GamePanel close and ) : null} +# We need to count what divs are open +null_pos = text.rfind(") : null}") +print(f"\n) : null}} at char {null_pos}") +context_before_null = text[null_pos-500:null_pos+10] +print(f"\nContext before ) : null}}:") +for i, line in enumerate(context_before_null.split('\n')): + print(f" {i}: {repr(line)}") diff --git a/apps/web/fix_app.py b/apps/web/fix_app.py new file mode 100644 index 0000000..7223e0c --- /dev/null +++ b/apps/web/fix_app.py @@ -0,0 +1,31 @@ +import sys + +with open('src/App.tsx', 'r', encoding='utf-8') as f: + lines = f.readlines() + +platform_import = "import { PlatformContext } from './contexts/PlatformContext';\n" +router_import = "import { usePathname, useRouter } from 'next/navigation';\n" + +new_lines = [] +seen_platform_close = False +injected_imports = False + +for i, line in enumerate(lines): + # Inject imports right after React import line + if not injected_imports and "import React from 'react';" in line: + new_lines.append(line) + new_lines.append(platform_import) + new_lines.append(router_import) + injected_imports = True + continue + # Skip duplicate closing Provider tag + if '' in line: + if seen_platform_close: + print(f'Skipping duplicate closing tag at line {i}') + continue + seen_platform_close = True + new_lines.append(line) + +with open('src/App.tsx', 'w', encoding='utf-8') as f: + f.writelines(new_lines) +print(f'Done. Total lines: {len(new_lines)}') diff --git a/apps/web/fix_app2.py b/apps/web/fix_app2.py new file mode 100644 index 0000000..43b0f46 --- /dev/null +++ b/apps/web/fix_app2.py @@ -0,0 +1,178 @@ +""" +Fix App.tsx: +1. Ensure PlatformContext.Provider wraps the AppShell (and all content) +2. Remove stray/duplicate PlatformContext.Provider from the previous bad injection +3. Add useRouter / usePathname usage inside the App function +4. Ensure children prop is typed properly +""" + +with open('src/App.tsx', 'r', encoding='utf-8') as f: + content = f.read() + +# ----------------------------------------------------------------- +# Step 1: Find and remove the broken PlatformContext.Provider block +# that was injected *before* AppShell (added by previous Python script) +# It starts with: " \n" (then the \n \n
+# ----------------------------------------------------------------- + +# Build the provider value string +provider_value = ''' { void copyLiveMatchLink(matchId); }, + }}> +''' + +# Find the main return JSX starting point +# It's: " return (\n <>\n
+# Current end: +# +# <- this was the duplicate we removed +#
+# +# +# New end should be: +# +#
+#
+# +# ----------------------------------------------------------------- + +# The current (broken) end has right after +# We need to move it to just before + +old_end = " \n \n
\n \n );\n}" +new_end = " \n \n \n \n );\n}" + +if old_end in content: + content = content.replace(old_end, new_end, 1) + print("Fixed PlatformContext.Provider closing position") +else: + # Try alternate - maybe whitespace differs + print("WARNING: Could not find PlatformContext.Provider closing - checking manually") + # Find end of file + end_idx = content.rfind('') + appl_idx = content.rfind('') + div_idx = content.rfind('', end_idx) + print(f" at char {appl_idx}") + print(f" at char {end_idx}") + print(f" Last after at char {div_idx}") + +# ----------------------------------------------------------------- +# Step 4: Add useRouter/usePathname usage if missing +# ----------------------------------------------------------------- + +if 'const router = useRouter()' not in content and 'useRouter' in content: + # Inject after: " const [activePage, setActivePage] = React.useState('Play');" + old_state = " const [activePage, setActivePage] = React.useState('Play');" + new_state = """ const [activePage, setActivePage] = React.useState('Play'); + const router = useRouter(); + const pathname = usePathname(); + React.useEffect(() => { + if (!pathname) return; + if (pathname === '/play' || pathname === '/') setActivePage('Play'); + else if (pathname === '/watch') setActivePage('Watch'); + else if (pathname === '/history') setActivePage('History'); + else if (pathname === '/friends') setActivePage('Friends'); + else if (pathname === '/inbox') setActivePage('Inbox'); + else if (pathname === '/profiles') setActivePage('Profiles'); + else if (pathname === '/cards') setActivePage('Cards'); + else if (pathname === '/rankings') setActivePage('Rankings'); + else if (pathname === '/community') setActivePage('Community'); + else if (pathname === '/status') setActivePage('Status'); + else if (pathname === '/account') setActivePage('Account'); + else if (pathname === '/admin') setActivePage('Admin'); + else if (pathname.startsWith('/match/')) setActivePage('Match'); + }, [pathname]);""" + if old_state in content: + content = content.replace(old_state, new_state, 1) + print("Added useRouter/usePathname usage") + +# ----------------------------------------------------------------- +# Step 5: Fix children prop signature +# ----------------------------------------------------------------- +old_sig = "export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceHttpBase?: string; matchServiceWsBase?: string } })" +new_sig = "export default function App({ runtimeConfig, children }: { runtimeConfig?: { matchServiceHttpBase?: string; matchServiceWsBase?: string }, children?: React.ReactNode })" +if old_sig in content: + content = content.replace(old_sig, new_sig, 1) + print("Fixed children prop signature") +elif 'children' in content and 'export default function App(' in content: + print("children prop already present") + +# ----------------------------------------------------------------- +# Step 6: Fix children rendering +# Replace "{showPlayHub ? (" with the conditional children version +# ----------------------------------------------------------------- +old_render = " {showPlayHub ? (" +new_render = " {(children && activePage !== 'Match') ? children : showPlayHub ? (" +if old_render in content and new_render not in content: + content = content.replace(old_render, new_render, 1) + print("Fixed children rendering") +elif new_render in content: + print("Children rendering already fixed") +else: + print("WARNING: Could not fix children rendering") + +with open('src/App.tsx', 'w', encoding='utf-8') as f: + f.write(content) + +print(f"\nDone. Total chars: {len(content)}") diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index f536120..fb6addb 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,8 +1,34 @@ import path from 'node:path'; +const csp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-eval' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self' ws: wss: https:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +].join('; '); + const nextConfig = { transpilePackages: ['@chess404/contracts', '@chess404/game-core'], - outputFileTracingRoot: path.join(process.cwd(), '../..') + outputFileTracingRoot: path.join(process.cwd(), '../..'), + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { key: 'Content-Security-Policy', value: csp }, + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 09d76db..3eb0771 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next typegen && tsc -p tsconfig.json --noEmit", + "lint": "node ./scripts/lint-types.mjs", "test": "npm run lint" }, "dependencies": { diff --git a/apps/web/patch_app.py b/apps/web/patch_app.py new file mode 100644 index 0000000..121fdb7 --- /dev/null +++ b/apps/web/patch_app.py @@ -0,0 +1,273 @@ +""" +Atomic patch for App.tsx: +1. Add imports: PlatformContext, useRouter, usePathname, PlayerBar, GamePanel, MatchLayout, CardHand +2. Add children prop to App signature +3. Add useRouter / usePathname bridge effect after activePage state +4. Wrap entire return JSX in PlatformContext.Provider +5. Replace renderPlayerCard body with component +6. Swap Move-History+Engine column + Chat column for +7. Inject children-aware render logic +""" + +import re + +SRC = "src/App.tsx" + +with open(SRC, "r", encoding="utf-8") as f: + text = f.read() + +# ── PATCH 1: Add imports after "import React from 'react';" ───────────────── +NEW_IMPORTS = """\ +import { PlatformContext } from './contexts/PlatformContext'; +import { usePathname, useRouter } from 'next/navigation'; +import { PlayerBar } from './components/match/PlayerBar'; +import { GamePanel } from './components/match/GamePanel'; +""" +text = text.replace( + "import React from 'react';", + "import React from 'react';\n" + NEW_IMPORTS, + 1, +) + +# ── PATCH 2: Add `children` prop to App function signature ─────────────────── +text = text.replace( + "export default function App({ runtimeConfig }: { runtimeConfig?: { matchServiceHttpBase?: string; matchServiceWsBase?: string } })", + "export default function App({ runtimeConfig, children }: { runtimeConfig?: { matchServiceHttpBase?: string; matchServiceWsBase?: string }, children?: React.ReactNode })", + 1, +) + +# ── PATCH 3: Add router bridge after activePage useState ──────────────────── +ROUTER_BRIDGE = """ + // App Router pathname → activePage bridge + const router = useRouter(); + const pathname = usePathname(); + React.useEffect(() => { + if (!pathname) return; + if (pathname === '/' || pathname === '/play') setActivePage('Play'); + else if (pathname === '/watch') setActivePage('Watch'); + else if (pathname === '/history') setActivePage('History'); + else if (pathname === '/friends') setActivePage('Friends'); + else if (pathname === '/inbox') setActivePage('Inbox'); + else if (pathname === '/profiles') setActivePage('Profiles'); + else if (pathname === '/cards') setActivePage('Cards'); + else if (pathname === '/rankings') setActivePage('Rankings'); + else if (pathname === '/community') setActivePage('Community'); + else if (pathname === '/status') setActivePage('Status'); + else if (pathname === '/account') setActivePage('Account'); + else if (pathname === '/admin') setActivePage('Admin'); + else if (pathname.startsWith('/match/')) setActivePage('Match'); + }, [pathname]); +""" +text = text.replace( + " const [activePage, setActivePage] = React.useState('Play');", + " const [activePage, setActivePage] = React.useState('Play');" + ROUTER_BRIDGE, + 1, +) + +# ── PATCH 4: Replace renderPlayerCard body with ──────────────── +OLD_RENDER_PC = """\ + return ( +
""" + +NEW_RENDER_PC = """\ + return ( + + ); + }; + // END renderPlayerCard — DEAD CODE BELOW (replaced by component above) + const _oldRenderPlayerCard_DEAD = () => { + return ( +
+ search_from = pc_start + len(OLD_RENDER_PC) + func_end = text.find("\n };\n", search_from) + if func_end == -1: + print("WARNING: Could not find end of renderPlayerCard") + else: + func_end += len("\n };\n") + # Replace the entire function body return statement + old_body = text[pc_start:func_end] + new_body = """\ + return ( + + ); + }; +""" + text = text[:pc_start] + new_body + text[func_end:] + print(f"Replaced renderPlayerCard body (saved {len(old_body) - len(new_body)} chars)") + +# ── PATCH 5: Replace Move/Engine + Chat columns with ─────────── +# Target: the div wrapping Move History + Engine analysis (flex:2) +# AND the Chat div below it. +# We'll identify them by their distinctive markers. + +MOVE_ENGINE_MARKER = "
\n {/* Move History */}" +CHAT_START_MARKER = " {/* Chat */}\n
at same indent level + # We look for "
\n
\n" after chat_start + chat_end_marker = "\n \n \n" + chat_end_pos = text.find(chat_end_marker, chat_start_pos) + if chat_end_pos != -1: + # Include the closing tags + chat_end_pos += len(chat_end_marker) + + old_right_panel = text[me_start:chat_end_pos] + + new_right_panel = """ +
0 ? '#2ecc71' : ev.score < 0 ? '#e74c3c' : '#ecf0f1', textAlign: 'center', marginBottom: '8px' }}> + {ev.mate != null ? (ev.mate === 0 ? 'Mate' : `M${Math.abs(ev.mate)}`) : (ev.score / 100).toFixed(2)} +
+ {ev.best && ( +
+ Best: {uciToSan(ev.best, reviewIdx >= 0 ? (reviewBoard ?? board) : board)} ({ev.best}) +
+ )} + {ev.pv && ev.pv.length > 0 && ( +
+ {ev.pv.slice(0, 5).join(' ')} +
+ )} + + ) : ( +
+
Engine {engineOn ? 'calculating...' : 'off'}
+ {over && ( + + )} +
+ ) + } + /> + {/* ELO Stakes */} +""" + text = text[:me_start] + new_right_panel + text[chat_end_pos:] + print(f"Replaced Move/Engine+Chat with GamePanel (saved {len(old_right_panel) - len(new_right_panel)} chars)") + else: + print("WARNING: Could not find chat end marker") + else: + print("WARNING: Could not find Chat start marker") + +# ── PATCH 6: Wrap return JSX in PlatformContext.Provider ──────────────────── +PLATFORM_VALUE = """ { void copyLiveMatchLink(matchId); }, + }}> +""" + +# Inject PlatformContext.Provider after the opening fragment in return +RETURN_OPEN = " return (\n <>\n" +if RETURN_OPEN in text: + text = text.replace(RETURN_OPEN, RETURN_OPEN + PLATFORM_VALUE, 1) + print("Wrapped return JSX in PlatformContext.Provider") +else: + print("WARNING: Could not find return open") + +# Close it before the closing fragment +RETURN_CLOSE = "\n \n );\n}" +if RETURN_CLOSE in text: + text = text.replace(RETURN_CLOSE, "\n \n \n );\n}", 1) + print("Closed PlatformContext.Provider before ") +else: + print("WARNING: Could not find closing fragment") + +# ── PATCH 7: Children-aware rendering ──────────────────────────────────────── +SHOW_PLAY_HUB = " {showPlayHub ? (" +CHILDREN_RENDER = " {(children && activePage !== 'Match') ? children : showPlayHub ? (" +if SHOW_PLAY_HUB in text and CHILDREN_RENDER not in text: + text = text.replace(SHOW_PLAY_HUB, CHILDREN_RENDER, 1) + print("Added children-aware rendering") +elif CHILDREN_RENDER in text: + print("Children-aware rendering already present") + +with open(SRC, "w", encoding="utf-8") as f: + f.write(text) + +print(f"\nAll patches applied. Final size: {len(text)} chars") diff --git a/apps/web/public/pieces/black_knight_rook.png b/apps/web/public/pieces/black_knight_rook.png new file mode 100644 index 0000000..41b6432 Binary files /dev/null and b/apps/web/public/pieces/black_knight_rook.png differ diff --git a/apps/web/scripts/lint-types.mjs b/apps/web/scripts/lint-types.mjs new file mode 100644 index 0000000..ce838bd --- /dev/null +++ b/apps/web/scripts/lint-types.mjs @@ -0,0 +1,36 @@ +import { spawn } from "node:child_process"; + +const isWindows = process.platform === "win32"; +const pnpmBin = isWindows ? "pnpm.cmd" : "pnpm"; + +function run(args) { + return new Promise((resolve, reject) => { + const command = isWindows + ? spawn(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", `${pnpmBin} ${args.join(" ")}`], { + cwd: process.cwd(), + stdio: "inherit", + shell: false, + }) + : spawn(pnpmBin, args, { + cwd: process.cwd(), + stdio: "inherit", + shell: false, + }); + command.on("error", reject); + command.on("exit", (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${pnpmBin} ${args.join(" ")} exited with code ${code ?? 1}`)); + }); + }); +} + +try { + await run(["exec", "next", "typegen"]); + await run(["exec", "tsc", "-p", "tsconfig.json", "--noEmit"]); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/apps/web/src/AccountPage.tsx b/apps/web/src/AccountPage.tsx index ee26f98..dd323c0 100644 --- a/apps/web/src/AccountPage.tsx +++ b/apps/web/src/AccountPage.tsx @@ -1,13 +1,39 @@ import React from 'react'; +import { OFFICIAL_MATCH_MODES } from '@chess404/contracts'; +import type { MatchModeId } from '@chess404/contracts'; import { claimAccount, + confirmAccountEmailVerification, + confirmPasswordReset, + enableAccountPasswordLogin, + formatAccountRestrictionNotice, + fetchAccountEmailOutboxOverview, + fetchAccountAuthOverview, + fetchAccountSecurityOverview, + fetchAccountSessionOverview, fetchAccountArchivedMatches, fetchAccount, + loginAccountWithPassword, + logoutAccountSession, + registerAccountWithPassword, + requestAccountEmailVerification, + requestPasswordReset, + revokeAccountSessionToken, + revokeOtherAccountSessions, resumeAccountSession, + isAccountRestrictionError, + type AccountAuthOverview, + type AccountEmailDelivery, + type AccountEmailDeliveryOverview, type AccountProfile, type AccountRatingHistoryEntry, + type AccountPasswordResetRequestResult, + type AccountSecurityEventOverview, type AccountSession, + type AccountSessionOverview, + type AccountSessionRecord, type GuestProfile, + type GuestSession, type MatchArchiveEntry, } from './lib/platform-service'; @@ -17,6 +43,8 @@ const WHITE_GUEST_SECRET_STORAGE_KEY = 'chess404.guest.white.secret'; const BLACK_GUEST_SECRET_STORAGE_KEY = 'chess404.guest.black.secret'; const WHITE_GUEST_TOKEN_STORAGE_KEY = 'chess404.guest.white.token'; const BLACK_GUEST_TOKEN_STORAGE_KEY = 'chess404.guest.black.token'; +const WHITE_GUEST_TOKEN_EXPIRY_STORAGE_KEY = 'chess404.guest.white.token.expiresAt'; +const BLACK_GUEST_TOKEN_EXPIRY_STORAGE_KEY = 'chess404.guest.black.token.expiresAt'; const WHITE_ACCOUNT_ID_STORAGE_KEY = 'chess404.account.white.id'; const BLACK_ACCOUNT_ID_STORAGE_KEY = 'chess404.account.black.id'; @@ -40,10 +68,115 @@ function formatRatingDelta(delta: number): string { return delta > 0 ? `+${delta}` : `${delta}`; } +function describeSessionTokenFingerprint(sessionToken: string): string { + const normalized = sessionToken.trim(); + if (!normalized) { + return 'unknown-session'; + } + if (normalized.length <= 12) { + return normalized; + } + return `${normalized.slice(0, 8)}…${normalized.slice(-4)}`; +} + +function parseModeFilterValue(value: string): MatchModeId | '' { + return OFFICIAL_MATCH_MODES.some((mode) => mode.id === value as MatchModeId) ? (value as MatchModeId) : ''; +} + +function describeAccountEmailDeliveryKind(kind: string): string { + switch (kind) { + case 'account_email_verification': + return 'Verification email'; + case 'account_password_reset': + return 'Password reset email'; + default: + return kind.replace(/_/g, ' '); + } +} + +function describeAccountEmailDeliveryStatus(delivery: AccountEmailDelivery): string { + switch (delivery.status) { + case 'delivered': + return delivery.deliveredAt ? `Delivered ${formatDateTime(delivery.deliveredAt)}` : 'Delivered'; + case 'failed': + return delivery.failedAt ? `Failed ${formatDateTime(delivery.failedAt)}` : 'Failed'; + default: + return delivery.nextAttemptAt + ? `Retry scheduled ${formatDateTime(delivery.nextAttemptAt)}` + : `Queued ${formatDateTime(delivery.createdAt)}`; + } +} + +function describeAccountSecurityEvent(kind: string, detail?: string): string { + const resolvedDetail = detail?.trim() ?? ''; + switch (kind) { + case 'account_claimed': + return resolvedDetail ? `Account claimed as @${resolvedDetail}` : 'Account claimed'; + case 'password_login_enabled': + return resolvedDetail ? `Password sign-in enabled for ${resolvedDetail}` : 'Password sign-in enabled'; + case 'email_verification_requested': + return resolvedDetail ? `Verification email queued for ${resolvedDetail}` : 'Verification email queued'; + case 'email_verified': + return resolvedDetail ? `Email verified for ${resolvedDetail}` : 'Email verified'; + case 'password_login_succeeded': + return resolvedDetail ? `Signed in with password as @${resolvedDetail}` : 'Signed in with password'; + case 'password_reset_requested': + return resolvedDetail ? `Password reset requested for ${resolvedDetail}` : 'Password reset requested'; + case 'password_reset_completed': + return resolvedDetail ? `Password reset completed for @${resolvedDetail}` : 'Password reset completed'; + case 'session_signed_out': + return resolvedDetail ? `Signed out session ${resolvedDetail}` : 'Signed out active session'; + case 'session_revoked': + return resolvedDetail ? `Revoked session ${resolvedDetail}` : 'Revoked another device session'; + case 'other_sessions_revoked': + return resolvedDetail ? `Signed out ${resolvedDetail} other device session(s)` : 'Signed out other devices'; + default: + return kind.replace(/_/g, ' '); + } +} + +function mergeAccountEmailDelivery( + current: AccountEmailDeliveryOverview | null, + delivery: AccountEmailDelivery, +): AccountEmailDeliveryOverview { + const next = [delivery]; + for (const item of current?.deliveries ?? []) { + if (item.deliveryId === delivery.deliveryId) { + continue; + } + next.push(item); + } + return { deliveries: next.slice(0, 12) }; +} + +function parseAccountAuthActionUrl(value?: string | null): { + action: 'verify-email' | 'reset-password' | null; + accountId: string; + token: string; +} { + const resolved = value?.trim() ?? ''; + if (!resolved) { + return { action: null, accountId: '', token: '' }; + } + try { + const url = new URL(resolved, typeof window !== 'undefined' ? window.location.origin : 'http://127.0.0.1'); + const auth = url.searchParams.get('auth'); + const accountId = url.searchParams.get('account')?.trim() ?? ''; + const token = url.searchParams.get('token')?.trim() ?? ''; + if ((auth === 'verify-email' || auth === 'reset-password') && token) { + return { action: auth, accountId, token }; + } + } catch { + return { action: null, accountId: '', token: '' }; + } + return { action: null, accountId: '', token: '' }; +} + function readStoredGuestIdentity(side: 'white' | 'black'): { guestId?: string; sessionSecret?: string; sessionToken?: string; + sessionExpiresAt?: string; } { if (typeof window === 'undefined') { return {}; @@ -52,6 +185,7 @@ function readStoredGuestIdentity(side: 'white' | 'black'): { guestId: window.localStorage.getItem(side === 'white' ? WHITE_GUEST_ID_STORAGE_KEY : BLACK_GUEST_ID_STORAGE_KEY) ?? undefined, sessionSecret: window.localStorage.getItem(side === 'white' ? WHITE_GUEST_SECRET_STORAGE_KEY : BLACK_GUEST_SECRET_STORAGE_KEY) ?? undefined, sessionToken: window.localStorage.getItem(side === 'white' ? WHITE_GUEST_TOKEN_STORAGE_KEY : BLACK_GUEST_TOKEN_STORAGE_KEY) ?? undefined, + sessionExpiresAt: window.localStorage.getItem(side === 'white' ? WHITE_GUEST_TOKEN_EXPIRY_STORAGE_KEY : BLACK_GUEST_TOKEN_EXPIRY_STORAGE_KEY) ?? undefined, }; } @@ -88,6 +222,28 @@ function writeStoredAccountSession(side: 'white' | 'black', session: AccountSess window.localStorage.setItem(expiryKey, session.expiresAt); } +function writeStoredGuestSession(side: 'white' | 'black', session: GuestSession): void { + if (typeof window === 'undefined') { + return; + } + const idKey = side === 'white' ? WHITE_GUEST_ID_STORAGE_KEY : BLACK_GUEST_ID_STORAGE_KEY; + const secretKey = side === 'white' ? WHITE_GUEST_SECRET_STORAGE_KEY : BLACK_GUEST_SECRET_STORAGE_KEY; + const tokenKey = side === 'white' ? WHITE_GUEST_TOKEN_STORAGE_KEY : BLACK_GUEST_TOKEN_STORAGE_KEY; + const expiryKey = side === 'white' ? WHITE_GUEST_TOKEN_EXPIRY_STORAGE_KEY : BLACK_GUEST_TOKEN_EXPIRY_STORAGE_KEY; + window.localStorage.setItem(idKey, session.guest.guestId); + window.localStorage.setItem(secretKey, session.sessionSecret); + if ((session.sessionToken ?? '').trim()) { + window.localStorage.setItem(tokenKey, session.sessionToken ?? ''); + } else { + window.localStorage.removeItem(tokenKey); + } + if ((session.expiresAt ?? '').trim()) { + window.localStorage.setItem(expiryKey, session.expiresAt ?? ''); + } else { + window.localStorage.removeItem(expiryKey); + } +} + function suggestHandle(seed: string): string { const normalized = seed .toLowerCase() @@ -138,6 +294,10 @@ function describeOpponent(entry: MatchArchiveEntry, accountId: string): string { interface AccountPageProps { whiteProfile?: GuestProfile | null; blackProfile?: GuestProfile | null; + externalNotice?: string | null; + onOpenProfile?: (handle: string) => void; + onSeatAuthenticated?: (side: 'white' | 'black', guestSession: GuestSession, accountSession: AccountSession) => void; + onAuthStateChange?: () => void; } interface AccountSeatPanelProps { @@ -145,21 +305,51 @@ interface AccountSeatPanelProps { label: string; accent: string; guestProfile?: GuestProfile | null; + externalNotice?: string | null; + onOpenProfile?: (handle: string) => void; + onSeatAuthenticated?: (side: 'white' | 'black', guestSession: GuestSession, accountSession: AccountSession) => void; + onAuthStateChange?: () => void; } -function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountSeatPanelProps): React.ReactElement { +function AccountSeatPanel({ side, label, accent, guestProfile = null, externalNotice = null, onOpenProfile, onSeatAuthenticated, onAuthStateChange }: AccountSeatPanelProps): React.ReactElement { const [guestIdentity, setGuestIdentity] = React.useState(() => readStoredGuestIdentity(side)); const [accountSession, setAccountSession] = React.useState(null); const [accountProfile, setAccountProfile] = React.useState(null); const [handle, setHandle] = React.useState(''); + const [authEmail, setAuthEmail] = React.useState(''); + const [authPassword, setAuthPassword] = React.useState(''); + const [loginIdentifier, setLoginIdentifier] = React.useState(''); + const [loginPassword, setLoginPassword] = React.useState(''); const [loading, setLoading] = React.useState(true); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(''); const [notice, setNotice] = React.useState(''); const [selectedSeasonId, setSelectedSeasonId] = React.useState(''); + const [selectedModeId, setSelectedModeId] = React.useState(''); const [recentMatches, setRecentMatches] = React.useState([]); const [recentMatchesLoading, setRecentMatchesLoading] = React.useState(false); const [recentMatchesError, setRecentMatchesError] = React.useState(''); + const [sessionOverview, setSessionOverview] = React.useState(null); + const [sessionOverviewLoading, setSessionOverviewLoading] = React.useState(false); + const [sessionOverviewError, setSessionOverviewError] = React.useState(''); + const [authOverview, setAuthOverview] = React.useState(null); + const [authOverviewLoading, setAuthOverviewLoading] = React.useState(false); + const [authOverviewError, setAuthOverviewError] = React.useState(''); + const [emailOutboxOverview, setEmailOutboxOverview] = React.useState(null); + const [emailOutboxLoading, setEmailOutboxLoading] = React.useState(false); + const [emailOutboxError, setEmailOutboxError] = React.useState(''); + const [securityOverview, setSecurityOverview] = React.useState(null); + const [securityOverviewLoading, setSecurityOverviewLoading] = React.useState(false); + const [securityOverviewError, setSecurityOverviewError] = React.useState(''); + const [verificationAccountId, setVerificationAccountId] = React.useState(''); + const [verificationToken, setVerificationToken] = React.useState(''); + const [verificationPreviewToken, setVerificationPreviewToken] = React.useState(''); + const [verificationPreviewExpiry, setVerificationPreviewExpiry] = React.useState(''); + const [resetPreview, setResetPreview] = React.useState(null); + const [resetAccountId, setResetAccountId] = React.useState(''); + const [resetToken, setResetToken] = React.useState(''); + const [resetPassword, setResetPassword] = React.useState(''); + const appliedAuthQueryRef = React.useRef(null); React.useEffect(() => { setGuestIdentity(readStoredGuestIdentity(side)); @@ -183,6 +373,10 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS }); }, [accountProfile?.handle, guestIdentity.guestId, guestProfile?.displayName]); + React.useEffect(() => { + setLoginIdentifier(current => current.trim() ? current : (accountProfile?.handle ?? current)); + }, [accountProfile?.handle]); + const refreshStoredAccount = React.useCallback(async () => { setLoading(true); setError(''); @@ -202,22 +396,27 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS sessionToken: storedAccount.sessionToken, }); writeStoredAccountSession(side, session); + onAuthStateChange?.(); setAccountSession(session); setAccountProfile(current => current?.accountId === session.account.accountId ? current : session.account); setNotice(''); } catch (err) { writeStoredAccountSession(side, null); + onAuthStateChange?.(); setAccountSession(null); + setAuthOverview(null); try { const publicProfile = await fetchAccount(storedAccount.accountId); setAccountProfile(publicProfile); setHandle(publicProfile.handle); - setNotice('Stored account session expired. Claim again to renew it.'); + setNotice(isAccountRestrictionError(err) ? formatAccountRestrictionNotice(err.restriction) : 'Stored account session expired. Claim again to renew it.'); } catch { setAccountProfile(null); - setNotice(''); + setNotice(isAccountRestrictionError(err) ? formatAccountRestrictionNotice(err.restriction) : ''); } - if (err instanceof Error) { + if (isAccountRestrictionError(err)) { + setError(''); + } else if (err instanceof Error) { setError(err.message); } else { setError('Failed to resume account session.'); @@ -225,12 +424,226 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS } finally { setLoading(false); } - }, [side]); + }, [onAuthStateChange, side]); React.useEffect(() => { void refreshStoredAccount(); }, [refreshStoredAccount]); + React.useEffect(() => { + if (!accountSession) { + setSessionOverview(null); + setSessionOverviewLoading(false); + setSessionOverviewError(''); + return; + } + + let cancelled = false; + setSessionOverviewLoading(true); + setSessionOverviewError(''); + void fetchAccountSessionOverview({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + }) + .then((overview) => { + if (cancelled) { + return; + } + setSessionOverview(overview); + }) + .catch((err: unknown) => { + if (cancelled) { + return; + } + setSessionOverview(null); + setSessionOverviewError(err instanceof Error ? err.message : 'Failed to load active account sessions.'); + }) + .finally(() => { + if (!cancelled) { + setSessionOverviewLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [accountSession?.account.accountId, accountSession?.sessionToken]); + + React.useEffect(() => { + if (!accountSession) { + setAuthOverview(null); + setAuthOverviewLoading(false); + setAuthOverviewError(''); + return; + } + + let cancelled = false; + setAuthOverviewLoading(true); + setAuthOverviewError(''); + void fetchAccountAuthOverview({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + }) + .then((overview) => { + if (cancelled) { + return; + } + setAuthOverview(overview); + }) + .catch((err: unknown) => { + if (cancelled) { + return; + } + setAuthOverview(null); + if (isAccountRestrictionError(err)) { + writeStoredAccountSession(side, null); + onAuthStateChange?.(); + setAccountSession(null); + setNotice(formatAccountRestrictionNotice(err.restriction)); + setAuthOverviewError(''); + } else { + setAuthOverviewError(err instanceof Error ? err.message : 'Failed to load account authentication status.'); + } + }) + .finally(() => { + if (!cancelled) { + setAuthOverviewLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [accountSession?.account.accountId, accountSession?.sessionToken]); + + React.useEffect(() => { + if (!accountSession) { + setEmailOutboxOverview(null); + setEmailOutboxLoading(false); + setEmailOutboxError(''); + return; + } + + let cancelled = false; + setEmailOutboxLoading(true); + setEmailOutboxError(''); + void fetchAccountEmailOutboxOverview({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + limit: 8, + }) + .then((overview) => { + if (cancelled) { + return; + } + setEmailOutboxOverview(overview); + }) + .catch((err: unknown) => { + if (cancelled) { + return; + } + setEmailOutboxOverview(null); + setEmailOutboxError(err instanceof Error ? err.message : 'Failed to load account email deliveries.'); + }) + .finally(() => { + if (!cancelled) { + setEmailOutboxLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [accountSession?.account.accountId, accountSession?.sessionToken]); + + React.useEffect(() => { + if (!accountSession) { + setSecurityOverview(null); + setSecurityOverviewLoading(false); + setSecurityOverviewError(''); + return; + } + + let cancelled = false; + setSecurityOverviewLoading(true); + setSecurityOverviewError(''); + void fetchAccountSecurityOverview({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + limit: 10, + }) + .then((overview) => { + if (cancelled) { + return; + } + setSecurityOverview(overview); + }) + .catch((err: unknown) => { + if (cancelled) { + return; + } + setSecurityOverview(null); + setSecurityOverviewError(err instanceof Error ? err.message : 'Failed to load account security activity.'); + }) + .finally(() => { + if (!cancelled) { + setSecurityOverviewLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [accountSession?.account.accountId, accountSession?.sessionToken]); + + React.useEffect(() => { + const nextAccountId = authOverview?.accountId ?? accountSession?.account.accountId ?? ''; + setVerificationAccountId((current) => current.trim() ? current : nextAccountId); + if (authOverview?.email) { + setAuthEmail((current) => current.trim() ? current : authOverview.email ?? current); + } + }, [accountSession?.account.accountId, authOverview?.accountId, authOverview?.email]); + + React.useEffect(() => { + if (typeof window === 'undefined') { + return; + } + const url = new URL(window.location.href); + const auth = url.searchParams.get('auth'); + const accountId = url.searchParams.get('account')?.trim() ?? ''; + const token = url.searchParams.get('token')?.trim() ?? ''; + if ((auth !== 'verify-email' && auth !== 'reset-password') || !token) { + return; + } + + const activeAccountId = accountSession?.account.accountId ?? accountProfile?.accountId ?? ''; + if (accountId && activeAccountId && accountId !== activeAccountId) { + return; + } + if (!accountId && !activeAccountId && side !== 'white') { + return; + } + + const queryKey = `${side}:${auth}:${accountId}:${token}`; + if (appliedAuthQueryRef.current === queryKey) { + return; + } + appliedAuthQueryRef.current = queryKey; + + if (auth === 'verify-email') { + setVerificationAccountId(accountId || activeAccountId); + setVerificationToken(token); + setNotice('Verification link loaded. Confirm verification to finish the email check for this account.'); + setError(''); + return; + } + + setResetAccountId(accountId || activeAccountId); + setResetToken(token); + setNotice('Password reset link loaded. Enter a new password to finish account recovery.'); + setError(''); + }, [accountProfile?.accountId, accountSession?.account.accountId, side]); + React.useEffect(() => { const accountId = accountSession?.account.accountId ?? accountProfile?.accountId; if (!accountId) { @@ -238,7 +651,7 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS } let cancelled = false; - void fetchAccount(accountId, selectedSeasonId || undefined) + void fetchAccount(accountId, selectedSeasonId || undefined, selectedModeId || undefined) .then(profile => { if (cancelled) { return; @@ -255,7 +668,7 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS return () => { cancelled = true; }; - }, [accountProfile?.accountId, accountSession?.account.accountId, selectedSeasonId]); + }, [accountProfile?.accountId, accountSession?.account.accountId, selectedModeId, selectedSeasonId]); React.useEffect(() => { const accountId = accountSession?.account.accountId ?? accountProfile?.accountId; @@ -270,7 +683,7 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS setRecentMatchesLoading(true); setRecentMatchesError(''); - void fetchAccountArchivedMatches(accountId, 6, selectedSeasonId || undefined) + void fetchAccountArchivedMatches(accountId, 6, selectedSeasonId || undefined, selectedModeId || undefined) .then(matches => { if (cancelled) { return; @@ -293,7 +706,7 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS return () => { cancelled = true; }; - }, [accountProfile?.accountId, accountSession?.account.accountId, selectedSeasonId]); + }, [accountProfile?.accountId, accountSession?.account.accountId, selectedModeId, selectedSeasonId]); const submitClaim = React.useCallback(async () => { setBusy(true); @@ -313,17 +726,19 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS handle: desiredHandle, }); writeStoredAccountSession(side, session); + onAuthStateChange?.(); setAccountSession(session); setAccountProfile(session.account); setHandle(session.account.handle); setSelectedSeasonId(''); + setSelectedModeId(''); setNotice('Account session is active on this device.'); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to claim account.'); } finally { setBusy(false); } - }, [accountProfile?.handle, handle, side]); + }, [accountProfile?.handle, handle, onAuthStateChange, side]); const renewSession = React.useCallback(async () => { setBusy(true); @@ -336,75 +751,448 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS } }, [refreshStoredAccount]); - const profile = guestProfile; - const activeAccount = accountProfile ?? accountSession?.account ?? null; - const displayedRatingHistory = [...(activeAccount?.ratingHistory ?? [])].slice(-6).reverse(); - const displayedSeasonHistory = activeAccount?.seasonHistory?.slice(0, 4) ?? []; - const highlightedSeason = activeAccount?.selectedSeason ?? activeAccount?.currentSeason; - - return ( -
-
-
-
{label}
-
- {profile?.displayName ?? guestIdentity.guestId ?? 'Guest seat'} -
-
- {profile ? `Guest rating ${profile.rating} · ${profile.wins}W ${profile.losses}L ${profile.draws}D` : 'Waiting for guest profile bootstrap.'} -
-
-
- {accountSession ? 'Signed in locally' : activeAccount ? 'Claimed account' : 'Guest-only'} -
-
+ const enableLogin = React.useCallback(async () => { + if (!accountSession) { + setError('Claim or refresh this account before enabling sign-in.'); + return; + } + setBusy(true); + setError(''); + setNotice(''); + try { + const nextSession = await enableAccountPasswordLogin({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + email: authEmail, + password: authPassword, + }); + writeStoredAccountSession(side, nextSession); + onAuthStateChange?.(); + setAccountSession(nextSession); + setAccountProfile(nextSession.account); + setHandle(nextSession.account.handle); + setLoginIdentifier(current => current.trim() ? current : nextSession.account.handle); + setAuthPassword(''); + setNotice(`Password sign-in is enabled for ${authEmail.trim().toLowerCase()}.`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to enable account sign-in.'); + } finally { + setBusy(false); + } + }, [accountSession, authEmail, authPassword, onAuthStateChange, side]); -
- - - - -
+ const registerWithPassword = React.useCallback(async () => { + setBusy(true); + setError(''); + setNotice(''); + try { + const liveGuestIdentity = readStoredGuestIdentity(side); + const result = await registerAccountWithPassword({ + handle, + email: authEmail, + password: authPassword, + guestId: liveGuestIdentity.guestId, + sessionSecret: liveGuestIdentity.sessionSecret, + sessionToken: liveGuestIdentity.sessionToken, + }); + writeStoredGuestSession(side, result.guest); + writeStoredAccountSession(side, result.account); + onAuthStateChange?.(); + setGuestIdentity({ + guestId: result.guest.guest.guestId, + sessionSecret: result.guest.sessionSecret, + sessionToken: result.guest.sessionToken, + sessionExpiresAt: result.guest.expiresAt, + }); + setAccountSession(result.account); + setAccountProfile(result.account.account); + setAuthOverview(result.overview); + setHandle(result.account.account.handle); + setLoginIdentifier(result.account.account.handle); + setAuthPassword(''); + setLoginPassword(''); + setVerificationAccountId(result.overview.accountId); + setVerificationPreviewToken(result.previewToken ?? ''); + setVerificationPreviewExpiry(result.expiresAt ?? ''); + setVerificationToken(result.previewToken ?? ''); + setSelectedSeasonId(''); + setSelectedModeId(''); + if (result.delivery) { + setEmailOutboxOverview((current) => mergeAccountEmailDelivery(current, result.delivery!)); + } + onSeatAuthenticated?.(side, result.guest, result.account); + setNotice( + result.requestedVerification + ? `Account created as @${result.account.account.handle}. Verification is queued for ${result.overview.email ?? authEmail.trim().toLowerCase()}.` + : `Account created as @${result.account.account.handle}.` + ); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create account.'); + } finally { + setBusy(false); + } + }, [authEmail, authPassword, handle, onAuthStateChange, onSeatAuthenticated, side]); - {error && ( -
- {error} -
- )} + const requestVerification = React.useCallback(async () => { + if (!accountSession) { + setError('Claim or refresh this account before requesting verification.'); + return; + } + setBusy(true); + setError(''); + setNotice(''); + try { + const result = await requestAccountEmailVerification({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + }); + setAuthOverview(result.overview); + setVerificationAccountId(result.overview.accountId); + if (result.delivery) { + setEmailOutboxOverview((current) => mergeAccountEmailDelivery(current, result.delivery!)); + } + setVerificationPreviewToken(result.previewToken ?? ''); + setVerificationPreviewExpiry(result.expiresAt ?? ''); + setVerificationToken(result.previewToken ?? verificationToken); + setNotice(result.previewToken + ? `Verification preview generated for ${result.email ?? authEmail.trim().toLowerCase()}.` + : 'Verification request queued in the account email outbox.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to request email verification.'); + } finally { + setBusy(false); + } + }, [accountSession, authEmail, verificationToken]); - {notice && ( + const confirmVerification = React.useCallback(async () => { + const accountId = accountSession?.account.accountId ?? verificationAccountId ?? authOverview?.accountId ?? ''; + if (!accountId) { + setError('Account verification needs an active account first.'); + return; + } + setBusy(true); + setError(''); + setNotice(''); + try { + const overview = await confirmAccountEmailVerification({ + accountId, + token: verificationToken, + }); + setAuthOverview(overview); + setVerificationAccountId(overview.accountId); + setVerificationPreviewToken(''); + setVerificationPreviewExpiry(''); + setNotice(`Email verified for ${overview.email ?? authEmail.trim().toLowerCase()}.`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to verify email.'); + } finally { + setBusy(false); + } + }, [accountSession?.account.accountId, authEmail, authOverview?.accountId, verificationToken]); + + const loginWithPassword = React.useCallback(async () => { + setBusy(true); + setError(''); + setNotice(''); + try { + const result = await loginAccountWithPassword({ + identifier: loginIdentifier, + password: loginPassword, + }); + writeStoredAccountSession(side, result.account); + writeStoredGuestSession(side, result.guest); + onAuthStateChange?.(); + setAccountSession(result.account); + setAccountProfile(result.account.account); + setHandle(result.account.account.handle); + setLoginIdentifier(result.account.account.handle); + setGuestIdentity({ + guestId: result.guest.guest.guestId, + sessionSecret: result.guest.sessionSecret, + sessionToken: result.guest.sessionToken, + }); + onSeatAuthenticated?.(side, result.guest, result.account); + setLoginPassword(''); + setNotice(`Signed in as @${result.account.account.handle} on this seat.`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to sign in.'); + } finally { + setBusy(false); + } + }, [loginIdentifier, loginPassword, onAuthStateChange, onSeatAuthenticated, side]); + + const requestReset = React.useCallback(async () => { + setBusy(true); + setError(''); + setNotice(''); + try { + const result = await requestPasswordReset({ + identifier: loginIdentifier, + }); + setResetPreview(result); + setResetAccountId(result.previewAccountId ?? resetAccountId); + setResetToken(result.previewToken ?? resetToken); + if (result.delivery) { + setEmailOutboxOverview((current) => mergeAccountEmailDelivery(current, result.delivery!)); + } + setNotice(result.previewToken + ? `Password reset preview generated for ${result.email ?? loginIdentifier.trim()}.` + : 'If this account has a verified email, a reset request has been queued in the account email outbox.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to request password reset.'); + } finally { + setBusy(false); + } + }, [loginIdentifier, resetAccountId, resetToken]); + + const completeReset = React.useCallback(async () => { + setBusy(true); + setError(''); + setNotice(''); + try { + const result = await confirmPasswordReset({ + accountId: resetAccountId, + token: resetToken, + password: resetPassword, + }); + writeStoredAccountSession(side, result.account); + writeStoredGuestSession(side, result.guest); + onAuthStateChange?.(); + setAccountSession(result.account); + setAccountProfile(result.account.account); + setGuestIdentity({ + guestId: result.guest.guest.guestId, + sessionSecret: result.guest.sessionSecret, + sessionToken: result.guest.sessionToken, + }); + setLoginIdentifier(result.account.account.handle); + setLoginPassword(''); + setResetPassword(''); + setNotice(`Password reset complete for @${result.account.account.handle}.`); + onSeatAuthenticated?.(side, result.guest, result.account); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to complete password reset.'); + } finally { + setBusy(false); + } + }, [onAuthStateChange, onSeatAuthenticated, resetAccountId, resetPassword, resetToken, side]); + + const activeAccount = accountProfile ?? accountSession?.account ?? null; + + const logoutSession = React.useCallback(async () => { + if (!accountSession) { + return; + } + setBusy(true); + setError(''); + setNotice(''); + try { + await logoutAccountSession({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + }); + writeStoredAccountSession(side, null); + onAuthStateChange?.(); + setAccountSession(null); + setNotice('Account session signed out on this device.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to sign out.'); + } finally { + setBusy(false); + } + }, [accountSession, onAuthStateChange, side]); + + const signOutOtherSessions = React.useCallback(async () => { + if (!accountSession) { + return; + } + setBusy(true); + setError(''); + setNotice(''); + try { + await revokeOtherAccountSessions({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + }); + const overview = await fetchAccountSessionOverview({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + }); + setSessionOverview(overview); + setSessionOverviewError(''); + setNotice('Signed out every other active device for this account.'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to sign out other devices.'); + } finally { + setBusy(false); + } + }, [accountSession]); + + const revokeOtherSession = React.useCallback(async (revokeToken: string) => { + if (!accountSession) { + return; + } + setBusy(true); + setError(''); + setNotice(''); + try { + await revokeAccountSessionToken({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + revokeToken, + }); + const overview = await fetchAccountSessionOverview({ + accountId: accountSession.account.accountId, + sessionToken: accountSession.sessionToken, + }); + setSessionOverview(overview); + setSessionOverviewError(''); + setNotice(`Signed out session ${describeSessionTokenFingerprint(revokeToken)}.`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to revoke the selected session.'); + } finally { + setBusy(false); + } + }, [accountSession]); + + const copyProfileLink = React.useCallback(async (handle: string) => { + if (typeof window === 'undefined') { + return; + } + const profileUrl = `${window.location.origin}/?profile=${encodeURIComponent(handle)}`; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(profileUrl); + } else { + const textArea = document.createElement('textarea'); + textArea.value = profileUrl; + textArea.style.position = 'fixed'; + textArea.style.opacity = '0'; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } + setNotice(`Copied ${profileUrl}`); + } catch { + setNotice(profileUrl); + } + }, []); + + const copyTextValue = React.useCallback(async (value: string) => { + if (typeof window === 'undefined') { + return; + } + const resolved = value.trim(); + if (!resolved) { + return; + } + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(resolved); + } else { + const textArea = document.createElement('textarea'); + textArea.value = resolved; + textArea.style.position = 'fixed'; + textArea.style.opacity = '0'; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + } + setNotice(`Copied ${resolved}`); + } catch { + setNotice(resolved); + } + }, []); + + const loadEmailAction = React.useCallback((delivery: AccountEmailDelivery) => { + const parsed = parseAccountAuthActionUrl(delivery.actionUrl); + if (parsed.action === 'verify-email') { + setVerificationAccountId(parsed.accountId || verificationAccountId || activeAccount?.accountId || ''); + setVerificationToken(parsed.token); + setNotice(`Loaded verification action from ${delivery.email}.`); + return; + } + if (parsed.action === 'reset-password') { + setResetAccountId(parsed.accountId || activeAccount?.accountId || ''); + setResetToken(parsed.token); + setNotice(`Loaded password reset action from ${delivery.email}.`); + } + }, [activeAccount?.accountId, verificationAccountId]); + + const profile = guestProfile; + const activeSessions = sessionOverview?.sessions ?? []; + const currentSessionToken = accountSession?.sessionToken ?? ''; + const currentSessionRecord = activeSessions.find((session) => session.sessionToken === currentSessionToken) ?? null; + const otherSessions = activeSessions.filter((session) => session.sessionToken !== currentSessionToken); + const displayedRatingHistory = [...(activeAccount?.ratingHistory ?? [])].slice(-6).reverse(); + const displayedSeasonHistory = activeAccount?.seasonHistory?.slice(0, 4) ?? []; + const highlightedSeason = activeAccount?.selectedSeason ?? activeAccount?.currentSeason; + + return ( +
+
+
+
{label}
+
+ {profile?.displayName ?? guestIdentity.guestId ?? 'Guest seat'} +
+
+ {profile ? `Guest rating ${profile.rating} · ${profile.wins}W ${profile.losses}L ${profile.draws}D` : 'Waiting for guest profile bootstrap.'} +
+
+
+ {accountSession ? 'Signed in locally' : activeAccount ? 'Claimed account' : 'Guest-only'} +
+
+ +
+ + + + +
+ + {error && ( +
+ {error} +
+ )} + + {(externalNotice || notice) && (
- {notice} + {externalNotice || notice}
)} @@ -447,6 +1235,114 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS
Last seen: {formatDateTime(activeAccount.lastSeenAt)}
+
+ + +
+
+ )} + + {activeAccount && ( +
+
+
+
Active sessions
+
+ This account can now stay signed in on multiple devices. The current seat keeps one active session token, and you can revoke any of the others here. +
+
+ {accountSession && otherSessions.length > 0 && ( + + )} +
+ {sessionOverviewLoading ? ( +
Loading active account sessions...
+ ) : sessionOverviewError ? ( +
{sessionOverviewError}
+ ) : !accountSession ? ( +
+ Claim or sign in to this account to inspect active devices. +
+ ) : ( +
+ + {otherSessions.length === 0 ? ( +
+ No other active devices are signed in right now. +
+ ) : ( + otherSessions.map((record, index) => ( + void revokeOtherSession(record.sessionToken)} + busy={busy} + /> + )) + )} +
+ )}
)} @@ -460,6 +1356,28 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS gap: '10px', }}>
Season view
+
+ +
{highlightedSeason ? ( <>
@@ -474,7 +1392,7 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS ) : (
- No season summary exists yet because this account has not completed an account-owned rated result. + No season summary exists yet for the selected official mode because this account has not completed an account-owned rated result there.
)} {displayedSeasonHistory.length > 0 && ( @@ -556,7 +1474,7 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS gap: '10px', }}>
- {selectedSeasonId ? 'Season matches' : 'Recent account matches'} + {selectedSeasonId || selectedModeId ? 'Filtered account matches' : 'Recent account matches'}
{recentMatchesLoading ? (
Loading archived matches...
@@ -664,6 +1582,573 @@ function AccountSeatPanel({ side, label, accent, guestProfile = null }: AccountS )}
+ +
+
+
Enable account sign-in
+
+ Create a full reusable account directly, or add sign-in credentials to a handle you already claimed on this seat. +
+
+ + +
+ + {!accountSession && ( +
+ This path creates the account immediately and signs this seat in, while still bridging through the seat guest identity underneath. +
+ )} +
+
+ +
+
+
Email verification
+
+ Verified email is the gate for real recovery. Requests now queue into a durable account email outbox so verification and reset actions survive across sessions and devices. +
+
+ {authOverviewLoading ? ( +
Loading auth status...
+ ) : authOverview ? ( +
+
+ {authOverview.email ? authOverview.email : 'No account email configured yet'} +
+
+ {authOverview.emailVerified + ? `Verified ${formatDateTime(authOverview.emailVerifiedAt)}` + : authOverview.pendingEmailVerification + ? `Pending verification until ${formatDateTime(authOverview.verificationExpiresAt)}` + : 'Not verified yet'} +
+
+ ) : ( +
+ {authOverviewError || 'Claim and enable account sign-in to manage verification.'} +
+ )} +
+ +
+ + {verificationPreviewToken && ( +
+
Preview token: {verificationPreviewToken}
+ {verificationPreviewExpiry &&
Expires {formatDateTime(verificationPreviewExpiry)}
} +
+ )} + {verificationAccountId && ( +
+ Verification account: {verificationAccountId} +
+ )} +
+ +
+
+ +
+
+
Sign in on this seat
+
+ Use a handle or email. Successful sign-in also restores the playable guest identity for this seat. +
+
+ + +
+ + {accountSession && ( + + )} +
+
+
Password reset preview
+
+ Verified accounts can request a reset token. If preview mode is enabled, the token also appears here so recovery can still be tested locally while the real delivery pipeline runs. +
+
+ +
+ {resetPreview?.previewToken && ( +
+
Preview account: {resetPreview.previewAccountId}
+
Preview token: {resetPreview.previewToken}
+ {resetPreview.expiresAt &&
Expires {formatDateTime(resetPreview.expiresAt)}
} +
+ )} + + + +
+ +
+
+
+ +
+
+
Recent auth emails
+
+ This durable auth outbox tracks verification and password-reset delivery activity, including queued, delivered, retrying, and failed messages. You can still load the action link directly into this page or copy it for another device. +
+
+ {emailOutboxLoading ? ( +
Loading account email activity...
+ ) : emailOutboxError ? ( +
{emailOutboxError}
+ ) : !accountSession ? ( +
+ Sign in on this seat to inspect the durable auth outbox for this account. +
+ ) : (emailOutboxOverview?.deliveries.length ?? 0) === 0 ? ( +
+ No verification or reset emails have been queued for this account yet. +
+ ) : ( +
+ {(emailOutboxOverview?.deliveries ?? []).map((delivery) => ( +
+
+
+ {describeAccountEmailDeliveryKind(delivery.kind)} +
+
+ {delivery.status} +
+
+
+ {delivery.email} | {describeAccountEmailDeliveryStatus(delivery)} +
+
+ {delivery.subject} +
+
+ Attempts {delivery.attemptCount} + {delivery.provider ? ` | provider ${delivery.provider}` : ''} + {delivery.providerMessageId ? ` | id ${delivery.providerMessageId}` : ''} +
+ {delivery.failureReason && ( +
+ Last failure: {delivery.failureReason} +
+ )} + {delivery.actionUrl && ( +
+ {delivery.actionUrl} +
+ )} +
+ {delivery.actionUrl && ( + <> + + + + )} +
+
+ ))} +
+ )} +
+ +
+
+
Security activity
+
+ This audit trail tracks account-sensitive actions like sign-in, verification, password recovery, and device session changes. +
+
+ {securityOverviewLoading ? ( +
Loading security activity...
+ ) : securityOverviewError ? ( +
{securityOverviewError}
+ ) : !accountSession ? ( +
+ Sign in on this seat to inspect the account security activity feed. +
+ ) : (securityOverview?.events.length ?? 0) === 0 ? ( +
+ No recorded security activity exists for this account yet. +
+ ) : ( +
+ {(securityOverview?.events ?? []).map((event) => ( +
+
+ {describeAccountSecurityEvent(event.kind, event.detail)} +
+
+ {formatDateTime(event.createdAt)} +
+
+ ))} +
+ )} +
)} @@ -685,6 +2170,78 @@ function MetaTile({ label, value }: { label: string; value: string }): React.Rea ); } +function AccountSessionCard({ + label, + accent, + record, + fallbackExpiresAt, + fallbackSessionToken, + actionLabel, + onAction, + busy = false, + disabledAction = false, +}: { + label: string; + accent: string; + record: AccountSessionRecord | null; + fallbackExpiresAt?: string; + fallbackSessionToken?: string; + actionLabel: string; + onAction?: () => void; + busy?: boolean; + disabledAction?: boolean; +}): React.ReactElement { + const sessionToken = record?.sessionToken ?? fallbackSessionToken ?? ''; + const createdAt = record?.createdAt; + const lastSeenAt = record?.lastSeenAt; + const expiresAt = record?.expiresAt ?? fallbackExpiresAt; + + return ( +
+
+
{label}
+
+ {describeSessionTokenFingerprint(sessionToken)} +
+
+
+ Expires {formatDateTime(expiresAt)} +
+
+ Last active {formatDateTime(lastSeenAt)} + {createdAt ? ` · Created ${formatDateTime(createdAt)}` : ''} +
+
+ +
+
+ ); +} + function RatingHistoryRow({ entry }: { entry: AccountRatingHistoryEntry }): React.ReactElement { const badgeColor = entry.result === 'win' @@ -777,6 +2334,10 @@ function SeasonSummaryRow({ summary }: { summary: NonNullable
Account Layer
-

Guest-to-account upgrade

+

Accounts and identity

- This is the first real account slice on top of guest identity. Each seat can claim a reusable handle and keep a renewable local account session without changing the live match flow yet. + Chess404 still bridges through seat guest identity for live play, but this page now supports direct account creation, cross-device sign-in, verification, and recovery so the platform can move toward real account-first onboarding without breaking hosted matches.
@@ -799,8 +2360,8 @@ export default function AccountPage({ gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: '18px', }}> - - + + ); diff --git a/apps/web/src/AdminModerationPage.tsx b/apps/web/src/AdminModerationPage.tsx new file mode 100644 index 0000000..c3ff64e --- /dev/null +++ b/apps/web/src/AdminModerationPage.tsx @@ -0,0 +1,569 @@ +import * as React from 'react'; +import { + fetchModerationAdminOverview, + resolveModerationReport, + type ModerationAdminOverview, +} from './lib/platform-service'; + +interface AdminModerationPageProps { + accountId?: string | null; + sessionToken?: string | null; + onOpenProfile?: (handle: string) => void; +} + +const STATUS_OPTIONS: Array<{ value: string; label: string }> = [ + { value: '', label: 'All reports' }, + { value: 'open', label: 'Open' }, + { value: 'under_review', label: 'Under review' }, + { value: 'resolved_actioned', label: 'Resolved: actioned' }, + { value: 'resolved_dismissed', label: 'Resolved: dismissed' }, +]; + +function formatDateTime(value?: string | null): string { + if (!value) { + return 'Unknown time'; + } + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return value; + } + return new Date(timestamp).toLocaleString(); +} + +export default function AdminModerationPage({ + accountId, + sessionToken, + onOpenProfile, +}: AdminModerationPageProps): React.ReactElement { + const [overview, setOverview] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(''); + const [busyReportId, setBusyReportId] = React.useState(''); + const [filterStatus, setFilterStatus] = React.useState(''); + const [notes, setNotes] = React.useState>({}); + + const loadOverview = React.useCallback(async (nextStatus = filterStatus) => { + if (!accountId || !sessionToken) { + setOverview(null); + setError(''); + return; + } + setLoading(true); + setError(''); + try { + const next = await fetchModerationAdminOverview({ + accountId, + sessionToken, + limit: 24, + status: nextStatus, + }); + setOverview(next); + setFilterStatus(next.selectedStatus ?? nextStatus); + } catch (err) { + setOverview(null); + setError(err instanceof Error ? err.message : 'Failed to load moderation admin queue.'); + } finally { + setLoading(false); + } + }, [accountId, filterStatus, sessionToken]); + + React.useEffect(() => { + void loadOverview(filterStatus); + }, [loadOverview, filterStatus]); + + const handleResolve = React.useCallback(async ( + reportId: string, + action: 'under_review' | 'resolved_actioned' | 'resolved_dismissed', + restriction?: 'suspended' | 'banned' | 'clear' + ) => { + if (!accountId || !sessionToken) { + return; + } + setBusyReportId(reportId); + setError(''); + try { + const next = await resolveModerationReport({ + accountId, + sessionToken, + reportId, + action, + restriction, + note: notes[reportId] ?? '', + limit: 24, + status: filterStatus, + }); + setOverview(next); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update moderation review.'); + } finally { + setBusyReportId(''); + } + }, [accountId, filterStatus, notes, sessionToken]); + + const canManage = Boolean(accountId && sessionToken); + + return ( +
+
+

Moderation Admin

+
+ This review queue turns player reports into a real launch-grade moderation workflow. Admins can triage reports, move them into review, resolve them with action or dismissal, and apply real suspensions or bans when needed. +
+
+ + {!canManage ? ( +
+ Sign in with an account session on this device to load the moderation admin queue. +
+ ) : ( + <> +
+ + + {overview?.viewer?.handle ? ( +
+ Signed in as @{overview.viewer.handle} +
+ ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+
+
+
Report queue
+
+ Reports here are authoritative backend records, not browser-only flags. +
+
+
+ {overview ? `${overview.reports.length} visible report${overview.reports.length === 1 ? '' : 's'}` : 'No queue loaded yet'} +
+
+ + {(overview?.reports.length ?? 0) === 0 ? ( +
+ {loading ? 'Loading moderation reports…' : 'No reports match the current filter.'} +
+ ) : ( + overview?.reports.map((report) => ( +
+
+
+
+ + {report.status.replace(/_/g, ' ')} + + {report.category} +
+
+ {report.details?.trim() || 'No additional detail was provided by the reporting player.'} +
+
+
+
Reported {formatDateTime(report.createdAt)}
+
Updated {formatDateTime(report.updatedAt)}
+
+
+ +
+ + + +
+ + {report.resolutionNote ? ( +
+ Resolution note: {report.resolutionNote} +
+ ) : null} + + {report.targetRestriction ? ( +
+ {report.targetRestriction.kind} + {' '}is active for @{report.target.handle} + {report.targetRestriction.reason ? `: ${report.targetRestriction.reason}` : '.'} +
+ ) : null} + +