diff --git a/.env b/.env index 84fcc1a..9b6b8f4 100644 --- a/.env +++ b/.env @@ -50,5 +50,6 @@ EXPECTED_BACKUP_DB_NAME=skymoney PROD_DB_VOLUME_NAME=skymoney_pgdata ALLOW_EMPTY_PROD_VOLUME=0 EMAIL_VERIFY_DEV_CODE_EXPOSE=false -BREAK_GLASS_VERIFY_ENABLED=false +UNDER_CONSTRUCTION_ENABLED=true +BREAK_GLASS_VERIFY_ENABLED=true BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ diff --git a/.env.example b/.env.example index 71372fd..df70f39 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,7 @@ COOKIE_SECRET=replace-with-32+-chars EMAIL_VERIFY_DEV_CODE_EXPOSE=false BREAK_GLASS_VERIFY_ENABLED=false BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars +UNDER_CONSTRUCTION_ENABLED=false AUTH_MAX_FAILED_ATTEMPTS=5 AUTH_LOCKOUT_WINDOW_MS=900000 PASSWORD_RESET_TTL_MINUTES=30 diff --git a/.env.localdev b/.env.localdev index c678d03..6bc959c 100644 --- a/.env.localdev +++ b/.env.localdev @@ -28,4 +28,5 @@ SEED_DEFAULT_BUDGET=true BREAK_GLASS_VERIFY_ENABLED=true BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ EMAIL_VERIFY_DEV_CODE_EXPOSE=true +UNDER_CONSTRUCTION_ENABLED=false diff --git a/.env.localdev.example b/.env.localdev.example index 7eea660..b62f0ff 100644 --- a/.env.localdev.example +++ b/.env.localdev.example @@ -26,6 +26,7 @@ COOKIE_SECRET=replace-with-32+-chars EMAIL_VERIFY_DEV_CODE_EXPOSE=true BREAK_GLASS_VERIFY_ENABLED=true BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars +UNDER_CONSTRUCTION_ENABLED=false AUTH_DISABLED=false ALLOW_INSECURE_AUTH_FOR_DEV=false diff --git a/api/src/env.ts b/api/src/env.ts index 81dc406..107fe43 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -52,6 +52,7 @@ const Env = z.object({ EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false), BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false), BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(), + UNDER_CONSTRUCTION_ENABLED: BoolFromEnv.default(false), AUTH_DISABLED: BoolFromEnv.optional().default(false), ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false), SEED_DEFAULT_BUDGET: BoolFromEnv.default(true), @@ -96,6 +97,7 @@ const rawEnv = { EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE, BREAK_GLASS_VERIFY_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED, BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE, + UNDER_CONSTRUCTION_ENABLED: process.env.UNDER_CONSTRUCTION_ENABLED, AUTH_DISABLED: process.env.AUTH_DISABLED, ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV, SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET, diff --git a/api/src/server.ts b/api/src/server.ts index 9570373..b3f8c59 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -4,7 +4,7 @@ import rateLimit from "@fastify/rate-limit"; import fastifyCookie from "@fastify/cookie"; import fastifyJwt from "@fastify/jwt"; import argon2 from "argon2"; -import { randomUUID, createHash, randomInt, randomBytes } from "node:crypto"; +import { randomUUID, createHash, randomInt, randomBytes, timingSafeEqual } from "node:crypto"; import nodemailer from "nodemailer"; import { env } from "./env.js"; import { PrismaClient, Prisma } from "@prisma/client"; @@ -17,6 +17,9 @@ export type AppConfig = typeof env; const openPaths = new Set([ "/health", + "/site-access/status", + "/site-access/unlock", + "/site-access/lock", "/auth/login", "/auth/register", "/auth/verify", @@ -108,6 +111,8 @@ const isInternalClientIp = (ip: string) => { }; const CSRF_COOKIE = "csrf"; const CSRF_HEADER = "x-csrf-token"; +const SITE_ACCESS_COOKIE = "skymoney_site_access"; +const SITE_ACCESS_MAX_AGE_SECONDS = 12 * 60 * 60; const HASH_OPTIONS: argon2.Options & { raw?: false } = { type: argon2.argon2id, memoryCost: 19_456, @@ -130,6 +135,18 @@ export async function buildApp(overrides: Partial = {}): Promise= 32 + ? createHash("sha256") + .update(`${config.COOKIE_SECRET}:${config.BREAK_GLASS_VERIFY_CODE}`) + .digest("hex") + : null; + + const hasSiteAccessBypass = (req: { cookies?: Record }) => { + const candidate = req.cookies?.[SITE_ACCESS_COOKIE]; + if (!siteAccessExpectedToken || typeof candidate !== "string") return false; + return safeEqual(candidate, siteAccessExpectedToken); + }; const app = Fastify({ logger: true, @@ -310,6 +327,13 @@ function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } +function safeEqual(a: string, b: string): boolean { + const left = Buffer.from(a, "utf8"); + const right = Buffer.from(b, "utf8"); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} + async function issueEmailToken( userId: string, type: EmailTokenType, @@ -754,6 +778,25 @@ app.decorate("ensureUser", async (userId: string) => { app.addHook("onRequest", async (req, reply) => { reply.header("x-request-id", String(req.id ?? "")); const path = pathOf(req.url ?? ""); + const isSiteAccessPath = + path === "/site-access/status" || + path === "/site-access/unlock" || + path === "/site-access/lock"; + + if ( + config.UNDER_CONSTRUCTION_ENABLED && + !isSiteAccessPath && + path !== "/health" && + path !== "/health/db" && + !hasSiteAccessBypass(req) + ) { + return reply.code(503).send({ + ok: false, + code: "UNDER_CONSTRUCTION", + message: "SkyMoney is temporarily under construction.", + requestId: String(req.id ?? ""), + }); + } // Open paths don't require authentication if (openPaths.has(path)) { @@ -838,6 +881,8 @@ app.decorate("ensureUser", async (userId: string) => { return; } if ( + path === "/site-access/unlock" || + path === "/site-access/lock" || path === "/auth/login" || path === "/auth/register" || path === "/auth/verify" || @@ -1503,6 +1548,65 @@ app.get("/auth/session", async (req, reply) => { }; }); +app.get("/site-access/status", async (req) => { + if (!config.UNDER_CONSTRUCTION_ENABLED) { + return { ok: true, enabled: false, unlocked: true }; + } + return { + ok: true, + enabled: true, + unlocked: hasSiteAccessBypass(req), + }; +}); + +app.post("/site-access/unlock", authRateLimit, async (req, reply) => { + const Body = z.object({ + code: z.string().min(1).max(512), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, code: "INVALID_PAYLOAD", message: "Invalid payload" }); + } + if (!config.UNDER_CONSTRUCTION_ENABLED) { + return { ok: true, enabled: false, unlocked: true }; + } + if (!config.BREAK_GLASS_VERIFY_ENABLED || !siteAccessExpectedToken) { + return reply.code(503).send({ + ok: false, + code: "UNDER_CONSTRUCTION_MISCONFIGURED", + message: "Under-construction access is not configured.", + }); + } + if (!safeEqual(parsed.data.code, config.BREAK_GLASS_VERIFY_CODE!)) { + return reply.code(401).send({ + ok: false, + code: "INVALID_ACCESS_CODE", + message: "Invalid access code.", + }); + } + + reply.setCookie(SITE_ACCESS_COOKIE, siteAccessExpectedToken, { + httpOnly: true, + sameSite: "lax", + secure: config.NODE_ENV === "production", + path: "/", + maxAge: SITE_ACCESS_MAX_AGE_SECONDS, + ...(cookieDomain ? { domain: cookieDomain } : {}), + }); + return { ok: true, enabled: true, unlocked: true }; +}); + +app.post("/site-access/lock", mutationRateLimit, async (_req, reply) => { + reply.clearCookie(SITE_ACCESS_COOKIE, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: config.NODE_ENV === "production", + ...(cookieDomain ? { domain: cookieDomain } : {}), + }); + return { ok: true, enabled: config.UNDER_CONSTRUCTION_ENABLED, unlocked: false }; +}); + app.post("/app/update-notice/ack", mutationRateLimit, async (req, reply) => { const Body = z.object({ version: z.coerce.number().int().nonnegative().optional(), diff --git a/api/src/services/api-errors.ts b/api/src/services/api-errors.ts new file mode 100644 index 0000000..5132960 --- /dev/null +++ b/api/src/services/api-errors.ts @@ -0,0 +1,64 @@ +export class ApiError extends Error { + statusCode: number; + code: string; + details?: unknown; + + constructor(statusCode: number, code: string, message: string, details?: unknown) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.code = code; + this.details = details; + } +} + +export function badRequest(code: string, message: string, details?: unknown) { + return new ApiError(400, code, message, details); +} + +export function unauthorized(code: string, message: string, details?: unknown) { + return new ApiError(401, code, message, details); +} + +export function forbidden(code: string, message: string, details?: unknown) { + return new ApiError(403, code, message, details); +} + +export function notFound(code: string, message: string, details?: unknown) { + return new ApiError(404, code, message, details); +} + +export function conflict(code: string, message: string, details?: unknown) { + return new ApiError(409, code, message, details); +} + +export function toErrorBody(err: unknown, requestId: string) { + if (err instanceof ApiError) { + return { + statusCode: err.statusCode, + body: { + ok: false, + code: err.code, + message: err.message, + requestId, + ...(err.details !== undefined ? { details: err.details } : {}), + }, + }; + } + + const fallback = err as { statusCode?: number; code?: string; message?: string }; + const statusCode = + typeof fallback?.statusCode === "number" ? fallback.statusCode : 500; + return { + statusCode, + body: { + ok: false, + code: fallback?.code ?? "INTERNAL", + message: + statusCode >= 500 + ? "Something went wrong" + : fallback?.message ?? "Bad request", + requestId, + }, + }; +} diff --git a/api/src/services/budget-session.ts b/api/src/services/budget-session.ts new file mode 100644 index 0000000..312d751 --- /dev/null +++ b/api/src/services/budget-session.ts @@ -0,0 +1,70 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; + +type BudgetSessionAccessor = + | Pick + | Pick; + +function normalizeAvailableCents(value: number): bigint { + if (!Number.isFinite(value)) return 0n; + return BigInt(Math.max(0, Math.trunc(value))); +} + +export async function getLatestBudgetSession( + db: BudgetSessionAccessor, + userId: string +) { + return db.budgetSession.findFirst({ + where: { userId }, + orderBy: { periodStart: "desc" }, + }); +} + +export async function ensureBudgetSession( + db: BudgetSessionAccessor, + userId: string, + fallbackAvailableCents = 0, + now = new Date() +) { + const existing = await getLatestBudgetSession(db, userId); + if (existing) return existing; + + const start = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0) + ); + const end = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0) + ); + const normalizedAvailable = normalizeAvailableCents(fallbackAvailableCents); + + return db.budgetSession.create({ + data: { + userId, + periodStart: start, + periodEnd: end, + totalBudgetCents: normalizedAvailable, + allocatedCents: 0n, + fundedCents: 0n, + availableCents: normalizedAvailable, + }, + }); +} + +export async function ensureBudgetSessionAvailableSynced( + db: BudgetSessionAccessor, + userId: string, + availableCents: number +) { + const normalizedAvailable = normalizeAvailableCents(availableCents); + const session = await ensureBudgetSession( + db, + userId, + Number(normalizedAvailable) + ); + + if ((session.availableCents ?? 0n) === normalizedAvailable) return session; + + return db.budgetSession.update({ + where: { id: session.id }, + data: { availableCents: normalizedAvailable }, + }); +} diff --git a/api/src/services/category-shares.ts b/api/src/services/category-shares.ts new file mode 100644 index 0000000..40a7362 --- /dev/null +++ b/api/src/services/category-shares.ts @@ -0,0 +1,200 @@ +export type PercentCategory = { + id: string; + percent: number; + balanceCents: bigint | number | null; +}; + +type ShareRow = { + id: string; + share: number; +}; + +type ShareResult = + | { ok: true; shares: ShareRow[] } + | { ok: false; reason: "no_percent" | "insufficient_balances" }; + +function toWholeCents(value: bigint | number | null | undefined): number { + if (typeof value === "bigint") return Number(value); + if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value); + return 0; +} + +function normalizedAmountCents(amountCents: number): number { + if (!Number.isFinite(amountCents)) return 0; + return Math.max(0, Math.trunc(amountCents)); +} + +export function computePercentShares( + categories: PercentCategory[], + amountCents: number +): ShareResult { + const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); + if (percentTotal <= 0) return { ok: false, reason: "no_percent" }; + + const shares = categories.map((cat) => { + const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal; + const floored = Math.floor(raw); + return { + id: cat.id, + balanceCents: toWholeCents(cat.balanceCents), + share: floored, + frac: raw - floored, + }; + }); + + let remainder = + normalizedAmountCents(amountCents) - shares.reduce((sum, s) => sum + s.share, 0); + shares + .slice() + .sort((a, b) => b.frac - a.frac) + .forEach((s) => { + if (remainder > 0) { + s.share += 1; + remainder -= 1; + } + }); + + if (shares.some((s) => s.share > s.balanceCents)) { + return { ok: false, reason: "insufficient_balances" }; + } + + return { ok: true, shares: shares.map(({ id, share }) => ({ id, share })) }; +} + +export function computeWithdrawShares( + categories: PercentCategory[], + amountCents: number +): ShareResult { + const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); + if (percentTotal <= 0) return { ok: false, reason: "no_percent" }; + + const working = categories.map((cat) => ({ + id: cat.id, + percent: cat.percent, + balanceCents: toWholeCents(cat.balanceCents), + share: 0, + })); + + let remaining = normalizedAmountCents(amountCents); + let safety = 0; + + while (remaining > 0 && safety < 1000) { + safety += 1; + const eligible = working.filter((c) => c.balanceCents > 0 && c.percent > 0); + if (eligible.length === 0) break; + + const totalPercent = eligible.reduce((sum, cat) => sum + cat.percent, 0); + if (totalPercent <= 0) break; + + const provisional = eligible.map((cat) => { + const raw = (remaining * cat.percent) / totalPercent; + const floored = Math.floor(raw); + return { + id: cat.id, + raw, + floored, + remainder: raw - floored, + }; + }); + + let sumBase = provisional.reduce((sum, p) => sum + p.floored, 0); + let leftovers = remaining - sumBase; + provisional + .slice() + .sort((a, b) => b.remainder - a.remainder) + .forEach((p) => { + if (leftovers > 0) { + p.floored += 1; + leftovers -= 1; + } + }); + + let allocatedThisRound = 0; + for (const p of provisional) { + const entry = working.find((w) => w.id === p.id); + if (!entry) continue; + const take = Math.min(p.floored, entry.balanceCents); + if (take > 0) { + entry.balanceCents -= take; + entry.share += take; + allocatedThisRound += take; + } + } + + remaining -= allocatedThisRound; + if (allocatedThisRound === 0) break; + } + + if (remaining > 0) { + return { ok: false, reason: "insufficient_balances" }; + } + + return { + ok: true, + shares: working.map((c) => ({ id: c.id, share: c.share })), + }; +} + +export function computeOverdraftShares( + categories: PercentCategory[], + amountCents: number +): ShareResult { + const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); + if (percentTotal <= 0) return { ok: false, reason: "no_percent" }; + + const shares = categories.map((cat) => { + const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal; + const floored = Math.floor(raw); + return { + id: cat.id, + share: floored, + frac: raw - floored, + }; + }); + + let remainder = + normalizedAmountCents(amountCents) - shares.reduce((sum, s) => sum + s.share, 0); + shares + .slice() + .sort((a, b) => b.frac - a.frac) + .forEach((s) => { + if (remainder > 0) { + s.share += 1; + remainder -= 1; + } + }); + + return { ok: true, shares: shares.map(({ id, share }) => ({ id, share })) }; +} + +export function computeDepositShares( + categories: PercentCategory[], + amountCents: number +): ShareResult { + const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0); + if (percentTotal <= 0) return { ok: false, reason: "no_percent" }; + + const shares = categories.map((cat) => { + const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal; + const floored = Math.floor(raw); + return { + id: cat.id, + share: floored, + frac: raw - floored, + }; + }); + + let remainder = + normalizedAmountCents(amountCents) - shares.reduce((sum, s) => sum + s.share, 0); + shares + .slice() + .sort((a, b) => b.frac - a.frac) + .forEach((s) => { + if (remainder > 0) { + s.share += 1; + remainder -= 1; + } + }); + + return { ok: true, shares: shares.map(({ id, share }) => ({ id, share })) }; +} diff --git a/api/src/services/user-context.ts b/api/src/services/user-context.ts new file mode 100644 index 0000000..e8c7fb6 --- /dev/null +++ b/api/src/services/user-context.ts @@ -0,0 +1,19 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; + +export const DEFAULT_USER_TIMEZONE = "America/New_York"; + +type UserReader = + | Pick + | Pick; + +export async function getUserTimezone( + db: UserReader, + userId: string, + fallback: string = DEFAULT_USER_TIMEZONE +): Promise { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + return user?.timezone ?? fallback; +} diff --git a/docs/api-refactor-lightweight-plan.md b/docs/api-refactor-lightweight-plan.md new file mode 100644 index 0000000..4c7ee07 --- /dev/null +++ b/docs/api-refactor-lightweight-plan.md @@ -0,0 +1,67 @@ +# API Refactor Lightweight Plan + +## Goal +Reduce `api/src/server.ts` size and duplication with low-risk, incremental moves. + +Current state (2026-03-15): +- `server.ts` has ~4.8k lines and 50 endpoint registrations. +- Duplicate endpoint signatures also exist in `api/src/routes/*` but are not currently registered. + +## Refactor Guardrails +1. Keep route behavior identical while moving code. +2. Move one domain at a time; do not mix domains in one PR. +3. Do not redesign architecture (no repositories/DI/container work). +4. Extract only duplicated logic into shared helpers/services. +5. Preserve global hooks and plugin registration in `server.ts` until final cleanup. +6. Keep response shapes stable (`ok`, `code`, `message`, etc.) during moves. +7. Require tests to pass after each move phase. + +## Canonical Source Rule +`server.ts` is the canonical route logic right now. + +When moving a domain: +1. Copy current logic from `server.ts` into the domain route module. +2. Register the module. +3. Remove the original block from `server.ts`. +4. Confirm no duplicate route registrations remain. + +## Shared Helpers (Phase 0) +Create these helpers first to keep endpoint moves lightweight: + +1. `api/src/services/user-context.ts` +- `getUserTimezone(...)` +- Removes repeated user timezone lookup logic. + +2. `api/src/services/category-shares.ts` +- `computePercentShares(...)` +- `computeWithdrawShares(...)` +- `computeOverdraftShares(...)` +- `computeDepositShares(...)` +- Centralizes repeated category-share math. + +3. `api/src/services/budget-session.ts` +- `getLatestBudgetSession(...)` +- `ensureBudgetSession(...)` +- `ensureBudgetSessionAvailableSynced(...)` +- Prevents session/value drift bugs. + +4. `api/src/services/api-errors.ts` +- Standard typed error object and response body builder. +- Keeps endpoint error handling consistent. + +## Move Order (Incremental) +1. Low-risk endpoints: `health`, `session`, `me`, `user/config`. +2. `auth` + `account` endpoints. +3. `variable-categories` endpoints. +4. `transactions` endpoints. +5. `fixed-plans` endpoints. +6. `income`, `budget`, `payday` endpoints. +7. `dashboard` + `crisis-status`. +8. `admin` endpoints. + +## Definition of Done per Phase +1. Endpoints compile and register once. +2. Existing tests pass. +3. No route path/method changes. +4. No response contract changes. +5. `server.ts` net line count decreases. diff --git a/web/src/api/siteAccess.ts b/web/src/api/siteAccess.ts new file mode 100644 index 0000000..7142e0a --- /dev/null +++ b/web/src/api/siteAccess.ts @@ -0,0 +1,19 @@ +import { apiGet, apiPost } from "./http"; + +export type SiteAccessStatus = { + ok: boolean; + enabled: boolean; + unlocked: boolean; +}; + +export async function getSiteAccessStatus(): Promise { + return apiGet("/site-access/status"); +} + +export async function unlockSiteAccess(code: string): Promise { + return apiPost("/site-access/unlock", { code }); +} + +export async function lockSiteAccess(): Promise { + return apiPost("/site-access/lock"); +} diff --git a/web/src/components/BetaGate.tsx b/web/src/components/BetaGate.tsx index e8b497b..a553c36 100644 --- a/web/src/components/BetaGate.tsx +++ b/web/src/components/BetaGate.tsx @@ -1,5 +1,6 @@ import { type ReactNode, useEffect, useState } from "react"; import { Navigate, useLocation } from "react-router-dom"; +import { getSiteAccessStatus } from "../api/siteAccess"; const STORAGE_KEY = "skymoney_beta_access"; @@ -10,16 +11,43 @@ type Props = { export function BetaGate({ children }: Props) { const location = useLocation(); const [hasAccess, setHasAccess] = useState(null); + const [isEnabled, setIsEnabled] = useState(null); useEffect(() => { - setHasAccess(localStorage.getItem(STORAGE_KEY) === "true"); + let cancelled = false; + (async () => { + try { + const status = await getSiteAccessStatus(); + const unlocked = !!status.unlocked; + if (!cancelled) { + setIsEnabled(!!status.enabled); + setHasAccess(!status.enabled || unlocked); + } + if (status.enabled && unlocked) { + localStorage.setItem(STORAGE_KEY, "true"); + } + if (status.enabled && !unlocked) { + localStorage.removeItem(STORAGE_KEY); + } + } catch { + // Fallback for temporary API/network issues. + const localFallback = localStorage.getItem(STORAGE_KEY) === "true"; + if (!cancelled) { + setIsEnabled(true); + setHasAccess(localFallback); + } + } + })(); + return () => { + cancelled = true; + }; }, []); if (location.pathname === "/beta") { return <>{children}; } - if (hasAccess === null) { + if (hasAccess === null || isEnabled === null) { return (
Checking access… @@ -27,7 +55,7 @@ export function BetaGate({ children }: Props) { ); } - if (!hasAccess) { + if (isEnabled && !hasAccess) { return ; } diff --git a/web/src/pages/BetaAccessPage.tsx b/web/src/pages/BetaAccessPage.tsx index 3d31a76..5eb5aa2 100644 --- a/web/src/pages/BetaAccessPage.tsx +++ b/web/src/pages/BetaAccessPage.tsx @@ -1,26 +1,65 @@ -import { useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { betaAccessStorageKey } from "../components/BetaGate"; - -const ACCESS_CODE = "jodygavemeaccess123"; +import { getSiteAccessStatus, lockSiteAccess, unlockSiteAccess } from "../api/siteAccess"; export default function BetaAccessPage() { const navigate = useNavigate(); const [code, setCode] = useState(""); const [touched, setTouched] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [enabled, setEnabled] = useState(null); - const isValid = useMemo(() => code.trim() === ACCESS_CODE, [code]); - const isUnlocked = useMemo( - () => localStorage.getItem(betaAccessStorageKey) === "true", - [] + const [isUnlocked, setIsUnlocked] = useState( + localStorage.getItem(betaAccessStorageKey) === "true" ); - const handleSubmit = (event: React.FormEvent) => { + useEffect(() => { + let cancelled = false; + (async () => { + try { + const status = await getSiteAccessStatus(); + if (!cancelled) setEnabled(!!status.enabled); + } catch { + if (!cancelled) setEnabled(true); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setTouched(true); - if (!isValid) return; - localStorage.setItem(betaAccessStorageKey, "true"); - navigate("/login", { replace: true }); + setError(""); + if (!code.trim()) return; + setLoading(true); + try { + const result = await unlockSiteAccess(code.trim()); + if (!result.unlocked) { + setError("That code doesn’t match. Please try again."); + return; + } + localStorage.setItem(betaAccessStorageKey, "true"); + setIsUnlocked(true); + navigate("/login", { replace: true }); + } catch (err: any) { + setError(err?.message ?? "Unable to unlock access right now."); + } finally { + setLoading(false); + } + }; + + const handleClearAccess = async () => { + localStorage.removeItem(betaAccessStorageKey); + setIsUnlocked(false); + try { + await lockSiteAccess(); + } catch { + // noop + } }; return ( @@ -28,14 +67,15 @@ export default function BetaAccessPage() {
- Private beta + Under construction

- Welcome to SkyMoney + SkyMoney maintenance mode

- This build is private. If you’ve been given access, enter your code - below. If not, reach out to{" "} + Public access is temporarily paused while we ship updates. Enter + your maintenance access code to continue testing. If you need + access, reach out to{" "} {" "} for access.

+ {enabled === false && ( +

+ Maintenance mode is currently disabled. +

+ )}
@@ -58,6 +103,7 @@ export default function BetaAccessPage() { value={code} onChange={(event) => { setTouched(true); + setError(""); setCode(event.target.value); }} placeholder="Enter your code" @@ -66,28 +112,37 @@ export default function BetaAccessPage() {
- {touched && code.length > 0 && !isValid && ( + {touched && code.length > 0 && !!error && (
- That code doesn’t match. Please try again. + {error}
)} {isUnlocked && ( - +
+ + +
)}