added udner construction for file compaction, planning for unbloating
This commit is contained in:
3
.env
3
.env
@@ -50,5 +50,6 @@ EXPECTED_BACKUP_DB_NAME=skymoney
|
|||||||
PROD_DB_VOLUME_NAME=skymoney_pgdata
|
PROD_DB_VOLUME_NAME=skymoney_pgdata
|
||||||
ALLOW_EMPTY_PROD_VOLUME=0
|
ALLOW_EMPTY_PROD_VOLUME=0
|
||||||
EMAIL_VERIFY_DEV_CODE_EXPOSE=false
|
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
|
BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ COOKIE_SECRET=replace-with-32+-chars
|
|||||||
EMAIL_VERIFY_DEV_CODE_EXPOSE=false
|
EMAIL_VERIFY_DEV_CODE_EXPOSE=false
|
||||||
BREAK_GLASS_VERIFY_ENABLED=false
|
BREAK_GLASS_VERIFY_ENABLED=false
|
||||||
BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars
|
BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars
|
||||||
|
UNDER_CONSTRUCTION_ENABLED=false
|
||||||
AUTH_MAX_FAILED_ATTEMPTS=5
|
AUTH_MAX_FAILED_ATTEMPTS=5
|
||||||
AUTH_LOCKOUT_WINDOW_MS=900000
|
AUTH_LOCKOUT_WINDOW_MS=900000
|
||||||
PASSWORD_RESET_TTL_MINUTES=30
|
PASSWORD_RESET_TTL_MINUTES=30
|
||||||
|
|||||||
@@ -28,4 +28,5 @@ SEED_DEFAULT_BUDGET=true
|
|||||||
BREAK_GLASS_VERIFY_ENABLED=true
|
BREAK_GLASS_VERIFY_ENABLED=true
|
||||||
BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ
|
BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ
|
||||||
EMAIL_VERIFY_DEV_CODE_EXPOSE=true
|
EMAIL_VERIFY_DEV_CODE_EXPOSE=true
|
||||||
|
UNDER_CONSTRUCTION_ENABLED=false
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ COOKIE_SECRET=replace-with-32+-chars
|
|||||||
EMAIL_VERIFY_DEV_CODE_EXPOSE=true
|
EMAIL_VERIFY_DEV_CODE_EXPOSE=true
|
||||||
BREAK_GLASS_VERIFY_ENABLED=true
|
BREAK_GLASS_VERIFY_ENABLED=true
|
||||||
BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars
|
BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars
|
||||||
|
UNDER_CONSTRUCTION_ENABLED=false
|
||||||
|
|
||||||
AUTH_DISABLED=false
|
AUTH_DISABLED=false
|
||||||
ALLOW_INSECURE_AUTH_FOR_DEV=false
|
ALLOW_INSECURE_AUTH_FOR_DEV=false
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const Env = z.object({
|
|||||||
EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false),
|
EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false),
|
||||||
BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false),
|
BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false),
|
||||||
BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(),
|
BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(),
|
||||||
|
UNDER_CONSTRUCTION_ENABLED: BoolFromEnv.default(false),
|
||||||
AUTH_DISABLED: BoolFromEnv.optional().default(false),
|
AUTH_DISABLED: BoolFromEnv.optional().default(false),
|
||||||
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
|
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
|
||||||
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
|
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
|
||||||
@@ -96,6 +97,7 @@ const rawEnv = {
|
|||||||
EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE,
|
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_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED,
|
||||||
BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE,
|
BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE,
|
||||||
|
UNDER_CONSTRUCTION_ENABLED: process.env.UNDER_CONSTRUCTION_ENABLED,
|
||||||
AUTH_DISABLED: process.env.AUTH_DISABLED,
|
AUTH_DISABLED: process.env.AUTH_DISABLED,
|
||||||
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
|
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
|
||||||
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
|
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import rateLimit from "@fastify/rate-limit";
|
|||||||
import fastifyCookie from "@fastify/cookie";
|
import fastifyCookie from "@fastify/cookie";
|
||||||
import fastifyJwt from "@fastify/jwt";
|
import fastifyJwt from "@fastify/jwt";
|
||||||
import argon2 from "argon2";
|
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 nodemailer from "nodemailer";
|
||||||
import { env } from "./env.js";
|
import { env } from "./env.js";
|
||||||
import { PrismaClient, Prisma } from "@prisma/client";
|
import { PrismaClient, Prisma } from "@prisma/client";
|
||||||
@@ -17,6 +17,9 @@ export type AppConfig = typeof env;
|
|||||||
|
|
||||||
const openPaths = new Set([
|
const openPaths = new Set([
|
||||||
"/health",
|
"/health",
|
||||||
|
"/site-access/status",
|
||||||
|
"/site-access/unlock",
|
||||||
|
"/site-access/lock",
|
||||||
"/auth/login",
|
"/auth/login",
|
||||||
"/auth/register",
|
"/auth/register",
|
||||||
"/auth/verify",
|
"/auth/verify",
|
||||||
@@ -108,6 +111,8 @@ const isInternalClientIp = (ip: string) => {
|
|||||||
};
|
};
|
||||||
const CSRF_COOKIE = "csrf";
|
const CSRF_COOKIE = "csrf";
|
||||||
const CSRF_HEADER = "x-csrf-token";
|
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 } = {
|
const HASH_OPTIONS: argon2.Options & { raw?: false } = {
|
||||||
type: argon2.argon2id,
|
type: argon2.argon2id,
|
||||||
memoryCost: 19_456,
|
memoryCost: 19_456,
|
||||||
@@ -130,6 +135,18 @@ export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<Fast
|
|||||||
const isProd = config.NODE_ENV === "production";
|
const isProd = config.NODE_ENV === "production";
|
||||||
const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE;
|
const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE;
|
||||||
const cookieDomain = config.COOKIE_DOMAIN || undefined;
|
const cookieDomain = config.COOKIE_DOMAIN || undefined;
|
||||||
|
const siteAccessExpectedToken =
|
||||||
|
config.BREAK_GLASS_VERIFY_CODE && config.BREAK_GLASS_VERIFY_CODE.length >= 32
|
||||||
|
? createHash("sha256")
|
||||||
|
.update(`${config.COOKIE_SECRET}:${config.BREAK_GLASS_VERIFY_CODE}`)
|
||||||
|
.digest("hex")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const hasSiteAccessBypass = (req: { cookies?: Record<string, unknown> }) => {
|
||||||
|
const candidate = req.cookies?.[SITE_ACCESS_COOKIE];
|
||||||
|
if (!siteAccessExpectedToken || typeof candidate !== "string") return false;
|
||||||
|
return safeEqual(candidate, siteAccessExpectedToken);
|
||||||
|
};
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: true,
|
logger: true,
|
||||||
@@ -310,6 +327,13 @@ function hashToken(token: string) {
|
|||||||
return createHash("sha256").update(token).digest("hex");
|
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(
|
async function issueEmailToken(
|
||||||
userId: string,
|
userId: string,
|
||||||
type: EmailTokenType,
|
type: EmailTokenType,
|
||||||
@@ -754,6 +778,25 @@ app.decorate("ensureUser", async (userId: string) => {
|
|||||||
app.addHook("onRequest", async (req, reply) => {
|
app.addHook("onRequest", async (req, reply) => {
|
||||||
reply.header("x-request-id", String(req.id ?? ""));
|
reply.header("x-request-id", String(req.id ?? ""));
|
||||||
const path = pathOf(req.url ?? "");
|
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
|
// Open paths don't require authentication
|
||||||
if (openPaths.has(path)) {
|
if (openPaths.has(path)) {
|
||||||
@@ -838,6 +881,8 @@ app.decorate("ensureUser", async (userId: string) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
path === "/site-access/unlock" ||
|
||||||
|
path === "/site-access/lock" ||
|
||||||
path === "/auth/login" ||
|
path === "/auth/login" ||
|
||||||
path === "/auth/register" ||
|
path === "/auth/register" ||
|
||||||
path === "/auth/verify" ||
|
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) => {
|
app.post("/app/update-notice/ack", mutationRateLimit, async (req, reply) => {
|
||||||
const Body = z.object({
|
const Body = z.object({
|
||||||
version: z.coerce.number().int().nonnegative().optional(),
|
version: z.coerce.number().int().nonnegative().optional(),
|
||||||
|
|||||||
64
api/src/services/api-errors.ts
Normal file
64
api/src/services/api-errors.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
70
api/src/services/budget-session.ts
Normal file
70
api/src/services/budget-session.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
type BudgetSessionAccessor =
|
||||||
|
| Pick<PrismaClient, "budgetSession">
|
||||||
|
| Pick<Prisma.TransactionClient, "budgetSession">;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
200
api/src/services/category-shares.ts
Normal file
200
api/src/services/category-shares.ts
Normal file
@@ -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 })) };
|
||||||
|
}
|
||||||
19
api/src/services/user-context.ts
Normal file
19
api/src/services/user-context.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export const DEFAULT_USER_TIMEZONE = "America/New_York";
|
||||||
|
|
||||||
|
type UserReader =
|
||||||
|
| Pick<PrismaClient, "user">
|
||||||
|
| Pick<Prisma.TransactionClient, "user">;
|
||||||
|
|
||||||
|
export async function getUserTimezone(
|
||||||
|
db: UserReader,
|
||||||
|
userId: string,
|
||||||
|
fallback: string = DEFAULT_USER_TIMEZONE
|
||||||
|
): Promise<string> {
|
||||||
|
const user = await db.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { timezone: true },
|
||||||
|
});
|
||||||
|
return user?.timezone ?? fallback;
|
||||||
|
}
|
||||||
67
docs/api-refactor-lightweight-plan.md
Normal file
67
docs/api-refactor-lightweight-plan.md
Normal file
@@ -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.
|
||||||
19
web/src/api/siteAccess.ts
Normal file
19
web/src/api/siteAccess.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { apiGet, apiPost } from "./http";
|
||||||
|
|
||||||
|
export type SiteAccessStatus = {
|
||||||
|
ok: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
unlocked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSiteAccessStatus(): Promise<SiteAccessStatus> {
|
||||||
|
return apiGet<SiteAccessStatus>("/site-access/status");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlockSiteAccess(code: string): Promise<SiteAccessStatus> {
|
||||||
|
return apiPost<SiteAccessStatus>("/site-access/unlock", { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lockSiteAccess(): Promise<SiteAccessStatus> {
|
||||||
|
return apiPost<SiteAccessStatus>("/site-access/lock");
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
import { Navigate, useLocation } from "react-router-dom";
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { getSiteAccessStatus } from "../api/siteAccess";
|
||||||
|
|
||||||
const STORAGE_KEY = "skymoney_beta_access";
|
const STORAGE_KEY = "skymoney_beta_access";
|
||||||
|
|
||||||
@@ -10,16 +11,43 @@ type Props = {
|
|||||||
export function BetaGate({ children }: Props) {
|
export function BetaGate({ children }: Props) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
|
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
|
||||||
|
const [isEnabled, setIsEnabled] = useState<boolean | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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") {
|
if (location.pathname === "/beta") {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAccess === null) {
|
if (hasAccess === null || isEnabled === null) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[40vh] items-center justify-center text-sm muted">
|
<div className="flex min-h-[40vh] items-center justify-center text-sm muted">
|
||||||
Checking access…
|
Checking access…
|
||||||
@@ -27,7 +55,7 @@ export function BetaGate({ children }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (isEnabled && !hasAccess) {
|
||||||
return <Navigate to="/beta" replace />;
|
return <Navigate to="/beta" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,65 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { betaAccessStorageKey } from "../components/BetaGate";
|
import { betaAccessStorageKey } from "../components/BetaGate";
|
||||||
|
import { getSiteAccessStatus, lockSiteAccess, unlockSiteAccess } from "../api/siteAccess";
|
||||||
const ACCESS_CODE = "jodygavemeaccess123";
|
|
||||||
|
|
||||||
export default function BetaAccessPage() {
|
export default function BetaAccessPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [touched, setTouched] = useState(false);
|
const [touched, setTouched] = useState(false);
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [enabled, setEnabled] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const isValid = useMemo(() => code.trim() === ACCESS_CODE, [code]);
|
const [isUnlocked, setIsUnlocked] = useState(
|
||||||
const isUnlocked = useMemo(
|
localStorage.getItem(betaAccessStorageKey) === "true"
|
||||||
() => 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();
|
event.preventDefault();
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
if (!isValid) return;
|
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");
|
localStorage.setItem(betaAccessStorageKey, "true");
|
||||||
|
setIsUnlocked(true);
|
||||||
navigate("/login", { replace: 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 (
|
return (
|
||||||
@@ -28,14 +67,15 @@ export default function BetaAccessPage() {
|
|||||||
<div className="w-full max-w-xl card p-8 sm:p-10">
|
<div className="w-full max-w-xl card p-8 sm:p-10">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-xs uppercase tracking-[0.2em] muted">
|
<div className="text-xs uppercase tracking-[0.2em] muted">
|
||||||
Private beta
|
Under construction
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl sm:text-4xl font-semibold">
|
<h1 className="text-3xl sm:text-4xl font-semibold">
|
||||||
Welcome to SkyMoney
|
SkyMoney maintenance mode
|
||||||
</h1>
|
</h1>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
This build is private. If you’ve been given access, enter your code
|
Public access is temporarily paused while we ship updates. Enter
|
||||||
below. If not, reach out to{" "}
|
your maintenance access code to continue testing. If you need
|
||||||
|
access, reach out to{" "}
|
||||||
<a
|
<a
|
||||||
href="https://jodyholt.com"
|
href="https://jodyholt.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -46,6 +86,11 @@ export default function BetaAccessPage() {
|
|||||||
</a>{" "}
|
</a>{" "}
|
||||||
for access.
|
for access.
|
||||||
</p>
|
</p>
|
||||||
|
{enabled === false && (
|
||||||
|
<p className="text-sm text-emerald-500">
|
||||||
|
Maintenance mode is currently disabled.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
@@ -58,6 +103,7 @@ export default function BetaAccessPage() {
|
|||||||
value={code}
|
value={code}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setTouched(true);
|
setTouched(true);
|
||||||
|
setError("");
|
||||||
setCode(event.target.value);
|
setCode(event.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="Enter your code"
|
placeholder="Enter your code"
|
||||||
@@ -66,21 +112,22 @@ export default function BetaAccessPage() {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{touched && code.length > 0 && !isValid && (
|
{touched && code.length > 0 && !!error && (
|
||||||
<div className="text-sm text-red-500">
|
<div className="text-sm text-red-500">
|
||||||
That code doesn’t match. Please try again.
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn primary w-full"
|
className="btn primary w-full"
|
||||||
disabled={!isValid}
|
disabled={!code.trim() || loading}
|
||||||
>
|
>
|
||||||
Continue
|
{loading ? "Checking…" : "Continue"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isUnlocked && (
|
{isUnlocked && (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn w-full"
|
className="btn w-full"
|
||||||
@@ -88,6 +135,14 @@ export default function BetaAccessPage() {
|
|||||||
>
|
>
|
||||||
I already have access
|
I already have access
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn w-full"
|
||||||
|
onClick={handleClearAccess}
|
||||||
|
>
|
||||||
|
Clear local access
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user