From 9856317641c06d122e80ca2140c44012bd014b46 Mon Sep 17 00:00:00 2001 From: Ricearoni1245 Date: Mon, 9 Feb 2026 14:46:49 -0600 Subject: [PATCH] feat: email verification + delete confirmation + smtp/cors/prod hardening --- .env | 21 +- .env.example | 13 + api/package-lock.json | 35 +- api/package.json | 2 + .../migration.sql | 19 + .../migration.sql | 6 + .../migration.sql | 2 + api/prisma/schema.prisma | 18 + api/src/env.ts | 39 +- api/src/server.ts | 392 ++++++++++++++++-- bash.exe.stackdump | 55 +++ docker-compose.yml | 13 + web/.env.development | 2 +- web/src/App.tsx | 43 +- web/src/components/RequireAuth.tsx | 7 + web/src/components/UpdateNoticeModal.tsx | 21 + web/src/hooks/useAuthSession.ts | 13 +- web/src/main.tsx | 2 + web/src/pages/LoginPage.tsx | 12 +- web/src/pages/RegisterPage.tsx | 15 +- web/src/pages/VerifyPage.tsx | 126 ++++++ web/src/pages/settings/AccountSettings.tsx | 98 ++++- 22 files changed, 896 insertions(+), 58 deletions(-) create mode 100644 api/prisma/migrations/20260215000000_email_verification/migration.sql create mode 100644 api/prisma/migrations/20260217000000_email_token_enum_used/migration.sql create mode 100644 api/prisma/migrations/20260217002000_add_seen_update_version/migration.sql create mode 100644 bash.exe.stackdump create mode 100644 web/src/components/UpdateNoticeModal.tsx create mode 100644 web/src/pages/VerifyPage.tsx diff --git a/.env b/.env index 65da944..44cfbf5 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -NODE_ENV=development +NODE_ENV=production PORT=8080 POSTGRES_DB=skymoney POSTGRES_USER=skymoney_app @@ -8,7 +8,9 @@ BACKUP_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5 RESTORE_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/skymoney_restore_test ADMIN_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/postgres -CORS_ORIGIN=http://localhost:5173 +APP_ORIGIN=https://skymoneybudget.com +CORS_ORIGINS=https://skymoneybudget.com + RATE_LIMIT_MAX=500 RATE_LIMIT_WINDOW_MS=60000 @@ -16,3 +18,18 @@ JWT_SECRET=ee5e3882e7b739bcd37ead2449d06f285eb1254620ae77ae201687f03ce82629 COOKIE_SECRET=364f4fa81d297294f864391aff02e151498de7caa8aac8c135ab01ad17a1212f AUTH_DISABLED=0 SEED_DEFAULT_BUDGET=0 +COOKIE_DOMAIN=skymoneybudget.com + +SMTP_HOST=mail.jodyholt.com +SMTP_PORT=587 +SMTP_REQUIRE_TLS=true +SMTP_TLS_REJECT_UNAUTHORIZED=true +SMTP_USER=skymoney-smtp +SMTP_PASS=skymoneysmtp124521 +EMAIL_FROM=SkyMoney Budget +EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com +EMAIL_REPLY_TO=support@skymoneybudget.com + +UPDATE_NOTICE_VERSION=1 +UPDATE_NOTICE_TITLE=SkyMoney Update +UPDATE_NOTICE_BODY=We added email verification and account-delete confirmation \ No newline at end of file diff --git a/.env.example b/.env.example index 79f0d72..54e16ef 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,11 @@ NODE_ENV=development PORT=8080 CORS_ORIGIN=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,https://skymoneybudget.com AUTH_DISABLED=false SEED_DEFAULT_BUDGET=false ROLLOVER_SCHEDULE_CRON=0 6 * * * +APP_ORIGIN=http://localhost:5173 # Database (app runtime) POSTGRES_DB=skymoney @@ -21,3 +23,14 @@ ADMIN_DATABASE_URL=postgres://postgres:change-me@127.0.0.1:5432/postgres JWT_SECRET=replace-with-32+-chars COOKIE_SECRET=replace-with-32+-chars COOKIE_DOMAIN=skymoneybudget.com + +# Email (verification + delete confirmation) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_REQUIRE_TLS=true +SMTP_TLS_REJECT_UNAUTHORIZED=true +SMTP_USER=apikey-or-username +SMTP_PASS=change-me +EMAIL_FROM=SkyMoney +EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com +EMAIL_REPLY_TO=support@skymoneybudget.com diff --git a/api/package-lock.json b/api/package-lock.json index 36bcdbb..8af6ae0 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -17,10 +17,12 @@ "date-fns-tz": "^3.2.0", "fastify": "^5.6.2", "node-cron": "^4.2.1", + "nodemailer": "^7.0.13", "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^20.19.25", + "@types/nodemailer": "^7.0.9", "@types/supertest": "^6.0.3", "prisma": "^5.22.0", "supertest": "^6.3.4", @@ -1127,6 +1129,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/superagent": { "version": "8.1.9", "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", @@ -1802,9 +1814,9 @@ } }, "node_modules/fastify": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.6.2.tgz", - "integrity": "sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==", + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", "funding": [ { "type": "github", @@ -1817,7 +1829,7 @@ ], "license": "MIT", "dependencies": { - "@fastify/ajv-compiler": "^4.0.0", + "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", @@ -2278,6 +2290,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2443,9 +2464,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/api/package.json b/api/package.json index f7f51ea..0b57e1e 100644 --- a/api/package.json +++ b/api/package.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/node": "^20.19.25", + "@types/nodemailer": "^7.0.9", "@types/supertest": "^6.0.3", "prisma": "^5.22.0", "supertest": "^6.3.4", @@ -36,6 +37,7 @@ "date-fns-tz": "^3.2.0", "fastify": "^5.6.2", "node-cron": "^4.2.1", + "nodemailer": "^7.0.13", "zod": "^3.23.8" } } diff --git a/api/prisma/migrations/20260215000000_email_verification/migration.sql b/api/prisma/migrations/20260215000000_email_verification/migration.sql new file mode 100644 index 0000000..ec94470 --- /dev/null +++ b/api/prisma/migrations/20260215000000_email_verification/migration.sql @@ -0,0 +1,19 @@ +ALTER TABLE "User" +ADD COLUMN IF NOT EXISTS "emailVerified" BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE IF NOT EXISTS "EmailToken" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid()::text, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "EmailToken_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "EmailToken_userId_type_idx" ON "EmailToken"("userId", "type"); +CREATE INDEX IF NOT EXISTS "EmailToken_tokenHash_idx" ON "EmailToken"("tokenHash"); + +ALTER TABLE "EmailToken" +ADD CONSTRAINT "EmailToken_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; \ No newline at end of file diff --git a/api/prisma/migrations/20260217000000_email_token_enum_used/migration.sql b/api/prisma/migrations/20260217000000_email_token_enum_used/migration.sql new file mode 100644 index 0000000..f648358 --- /dev/null +++ b/api/prisma/migrations/20260217000000_email_token_enum_used/migration.sql @@ -0,0 +1,6 @@ +-- Harden email token lifecycle (used marker + lookup index) +ALTER TABLE "EmailToken" +ADD COLUMN IF NOT EXISTS "usedAt" TIMESTAMP(3); + +CREATE INDEX IF NOT EXISTS "EmailToken_userId_type_expiresAt_idx" +ON "EmailToken"("userId", "type", "expiresAt"); diff --git a/api/prisma/migrations/20260217002000_add_seen_update_version/migration.sql b/api/prisma/migrations/20260217002000_add_seen_update_version/migration.sql new file mode 100644 index 0000000..e9ccf1c --- /dev/null +++ b/api/prisma/migrations/20260217002000_add_seen_update_version/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "User" +ADD COLUMN IF NOT EXISTS "seenUpdateVersion" INTEGER NOT NULL DEFAULT 0; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 1c58467..cb750ed 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -25,6 +25,8 @@ model User { email String @unique passwordHash String? displayName String? + emailVerified Boolean @default(false) + seenUpdateVersion Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -43,6 +45,7 @@ model User { allocations Allocation[] transactions Transaction[] budgetSessions BudgetSession[] + emailTokens EmailToken[] } model VariableCategory { @@ -161,3 +164,18 @@ model BudgetSession { @@unique([userId, periodStart]) @@index([userId, periodStart]) } + +model EmailToken { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String // "signup" | "delete" + usedAt DateTime? + tokenHash String + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([userId, type]) + @@index([userId, type, expiresAt]) + @@index([tokenHash]) +} diff --git a/api/src/env.ts b/api/src/env.ts index 410a8b4..8a88e9e 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -14,6 +14,7 @@ const Env = z.object({ HOST: z.string().default("0.0.0.0"), DATABASE_URL: z.string().min(1), CORS_ORIGIN: z.string().optional(), + CORS_ORIGINS: z.string().optional(), RATE_LIMIT_MAX: z.coerce.number().int().positive().default(200), RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(60_000), JWT_SECRET: z.string().min(32), @@ -22,6 +23,22 @@ const Env = z.object({ AUTH_DISABLED: BoolFromEnv.optional().default(false), SEED_DEFAULT_BUDGET: BoolFromEnv.default(true), SESSION_TIMEOUT_MINUTES: z.coerce.number().int().positive().default(30), + APP_ORIGIN: z.string().min(1).default("http://localhost:5173"), + UPDATE_NOTICE_VERSION: z.coerce.number().int().nonnegative().default(0), + UPDATE_NOTICE_TITLE: z.string().min(1).default("SkyMoney Updated"), + UPDATE_NOTICE_BODY: z + .string() + .min(1) + .default("We shipped improvements and fixes. Please review the latest changes."), + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().int().positive().optional(), + SMTP_REQUIRE_TLS: BoolFromEnv.default(true), + SMTP_TLS_REJECT_UNAUTHORIZED: BoolFromEnv.default(true), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + EMAIL_FROM: z.string().min(1).default("SkyMoney "), + EMAIL_BOUNCE_FROM: z.string().optional(), + EMAIL_REPLY_TO: z.string().optional(), }); const rawEnv = { @@ -30,6 +47,7 @@ const rawEnv = { HOST: process.env.HOST, DATABASE_URL: process.env.DATABASE_URL, CORS_ORIGIN: process.env.CORS_ORIGIN, + CORS_ORIGINS: process.env.CORS_ORIGINS, RATE_LIMIT_MAX: process.env.RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS: process.env.RATE_LIMIT_WINDOW_MS, JWT_SECRET: process.env.JWT_SECRET ?? "dev-jwt-secret-change-me", @@ -37,13 +55,27 @@ const rawEnv = { COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, AUTH_DISABLED: process.env.AUTH_DISABLED, SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET, + SESSION_TIMEOUT_MINUTES: process.env.SESSION_TIMEOUT_MINUTES, + APP_ORIGIN: process.env.APP_ORIGIN, + UPDATE_NOTICE_VERSION: process.env.UPDATE_NOTICE_VERSION, + UPDATE_NOTICE_TITLE: process.env.UPDATE_NOTICE_TITLE, + UPDATE_NOTICE_BODY: process.env.UPDATE_NOTICE_BODY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_REQUIRE_TLS: process.env.SMTP_REQUIRE_TLS, + SMTP_TLS_REJECT_UNAUTHORIZED: process.env.SMTP_TLS_REJECT_UNAUTHORIZED, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + EMAIL_FROM: process.env.EMAIL_FROM, + EMAIL_BOUNCE_FROM: process.env.EMAIL_BOUNCE_FROM, + EMAIL_REPLY_TO: process.env.EMAIL_REPLY_TO, }; const parsed = Env.parse(rawEnv); if (parsed.NODE_ENV === "production") { - if (!parsed.CORS_ORIGIN) { - throw new Error("CORS_ORIGIN must be set in production."); + if (!parsed.CORS_ORIGIN && !parsed.CORS_ORIGINS) { + throw new Error("CORS_ORIGIN or CORS_ORIGINS must be set in production."); } if (rawEnv.AUTH_DISABLED && parsed.AUTH_DISABLED) { throw new Error("AUTH_DISABLED cannot be enabled in production."); @@ -57,6 +89,9 @@ if (parsed.NODE_ENV === "production") { if (parsed.COOKIE_SECRET.includes("dev-cookie-secret-change-me")) { throw new Error("COOKIE_SECRET must be set to a strong value in production."); } + if (!parsed.APP_ORIGIN) { + throw new Error("APP_ORIGIN must be set in production."); + } } export const env = parsed; diff --git a/api/src/server.ts b/api/src/server.ts index f5bb163..eeb924b 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -4,7 +4,8 @@ import rateLimit from "@fastify/rate-limit"; import fastifyCookie from "@fastify/cookie"; import fastifyJwt from "@fastify/jwt"; import argon2 from "argon2"; -import { randomUUID } from "node:crypto"; +import { randomUUID, createHash, randomInt } from "node:crypto"; +import nodemailer from "nodemailer"; import { env } from "./env.js"; import { PrismaClient, Prisma } from "@prisma/client"; import { z } from "zod"; @@ -14,7 +15,14 @@ import { rolloverFixedPlans } from "./jobs/rollover.js"; export type AppConfig = typeof env; -const openPaths = new Set(["/health", "/health/db", "/auth/login", "/auth/register"]); +const openPaths = new Set([ + "/health", + "/health/db", + "/auth/login", + "/auth/register", + "/auth/verify", + "/auth/verify/resend", +]); const mutationRateLimit = { config: { rateLimit: { @@ -92,6 +100,138 @@ const ensureCsrfCookie = (reply: any, existing?: string) => { return token; }; +const EMAIL_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes +const DELETE_TOKEN_TTL_MS = 10 * 60 * 1000; // 10 minutes +const EMAIL_TOKEN_COOLDOWN_MS = 60 * 1000; // 60 seconds + +const normalizeOrigin = (origin: string) => origin.replace(/\/$/, ""); + +const toPlainEmailAddress = (value?: string | null) => { + if (!value) return undefined; + const trimmed = value.trim(); + const match = trimmed.match(/<([^>]+)>/); + return (match?.[1] ?? trimmed).trim(); +}; + +const mailer = config.SMTP_HOST + ? nodemailer.createTransport({ + host: config.SMTP_HOST, + port: Number(config.SMTP_PORT ?? 587), + secure: false, + requireTLS: true, + tls: { + rejectUnauthorized: config.SMTP_TLS_REJECT_UNAUTHORIZED, + }, + auth: config.SMTP_USER + ? { + user: config.SMTP_USER, + pass: config.SMTP_PASS, + } + : undefined, + logger: true, + debug: true, + }) + : null; + +function buildEmailTextBody(content: string) { + return `${content}\n\n---\nSkyMoney Budget\nhttps://skymoneybudget.com\nNeed help? support@skymoneybudget.com`; +} + +function buildEmailHtmlBody(contentHtml: string) { + return `${contentHtml} +
+

+ SkyMoney Budget
+ skymoneybudget.com
+ Need help? support@skymoneybudget.com +

`; +} + +async function sendEmail({ + to, + subject, + text, + html, +}: { + to: string; + subject: string; + text: string; + html?: string; +}) { + const finalText = buildEmailTextBody(text); + const finalHtml = buildEmailHtmlBody(html ?? `

${text}

`); + + if (!mailer) { + // Dev fallback: log the email for manual copy + app.log.info({ to, subject }, "[email] mailer disabled; logged email content"); + console.log("[email]", { to, subject, text: finalText }); + return; + } + + const bounceFrom = + toPlainEmailAddress(config.EMAIL_BOUNCE_FROM) ?? + toPlainEmailAddress(config.EMAIL_FROM) ?? + "bounces@skymoneybudget.com"; + + try { + const info = await mailer.sendMail({ + from: config.EMAIL_FROM, + to, + subject, + text: finalText, + html: finalHtml, + replyTo: config.EMAIL_REPLY_TO || undefined, + envelope: { + from: bounceFrom, + to: [to], + }, + headers: { + "Auto-Submitted": "auto-generated", + "X-Auto-Response-Suppress": "All", + }, + }); + app.log.info({ to, subject, messageId: info.messageId }, "[email] sent"); + } catch (err) { + app.log.error({ err, to, subject }, "[email] failed"); + throw err; + } +} + +function generateCode(length = 6) { + const min = 10 ** (length - 1); + const max = 10 ** length - 1; + return String(randomInt(min, max + 1)); +} + +function hashToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +async function issueEmailToken( + userId: string, + type: "signup" | "delete", + ttlMs: number +) { + const code = generateCode(6); + const tokenHash = hashToken(code); + const expiresAt = new Date(Date.now() + ttlMs); + await app.prisma.emailToken.create({ + data: { + userId, + type, + tokenHash, + expiresAt, + }, + }); + return { code, expiresAt }; +} + +async function clearEmailTokens(userId: string, type?: "signup" | "delete") { + await app.prisma.emailToken.deleteMany({ + where: type ? { userId, type } : { userId }, + }); +} + /** * Calculate the next due date based on frequency for rollover */ @@ -365,19 +505,24 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) { }); } + const configuredOrigins = (config.CORS_ORIGINS || config.CORS_ORIGIN || "") + .split(",") + .map((s) => normalizeOrigin(s.trim())) + .filter(Boolean); + await app.register(cors, { - origin: (() => { - if (!config.CORS_ORIGIN) return true; - const allow = config.CORS_ORIGIN.split(",") - .map((s) => s.trim()) - .filter(Boolean); - return (origin, cb) => { - if (!origin) return cb(null, true); - cb(null, allow.includes(origin)); - }; - })(), - credentials: true, -}); + origin: (() => { + // Keep local/dev friction-free. + if (config.NODE_ENV !== "production") return true; + if (configuredOrigins.length === 0) return true; + return (origin, cb) => { + if (!origin) return cb(null, true); + const normalized = normalizeOrigin(origin); + cb(null, configuredOrigins.includes(normalized)); + }; + })(), + credentials: true, + }); await app.register(rateLimit, { max: config.RATE_LIMIT_MAX, @@ -398,6 +543,17 @@ async function seedDefaultBudget(prisma: PrismaClient, userId: string) { }, }); + app.addHook("onReady", async () => { + if (!mailer) return; + try { + await mailer.verify(); + app.log.info("[email] SMTP verified"); + } catch (err) { + app.log.error({ err }, "[email] SMTP verify failed"); + if (config.NODE_ENV === "production") throw err; + } + }); + { const prisma = new PrismaClient(); app.decorate("prisma", prisma); @@ -456,7 +612,7 @@ app.decorate("ensureUser", async (userId: string) => { if (method === "GET" || method === "HEAD" || method === "OPTIONS") { return; } - if (path === "/auth/login" || path === "/auth/register") { + if (path === "/auth/login" || path === "/auth/register" || path === "/auth/verify" || path === "/auth/verify/resend") { return; } const cookieToken = (req.cookies as any)?.[CSRF_COOKIE]; @@ -471,6 +627,11 @@ const AuthBody = z.object({ password: z.string().min(8), }); +const VerifyBody = z.object({ + email: z.string().email(), + code: z.string().min(4), +}); + const AllocationOverrideSchema = z.object({ type: z.enum(["fixed", "variable"]), id: z.string().min(1), @@ -507,23 +668,22 @@ app.post( email: normalizedEmail, passwordHash: hash, displayName: email.split("@")[0] || null, + emailVerified: false, }, }); if (config.SEED_DEFAULT_BUDGET) { await seedDefaultBudget(app.prisma, user.id); } - const token = await reply.jwtSign({ sub: user.id }); - const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; // Convert to seconds - reply.setCookie("session", token, { - httpOnly: true, - sameSite: "lax", - secure: config.NODE_ENV === "production", - path: "/", - maxAge, - ...(cookieDomain ? { domain: cookieDomain } : {}), + await clearEmailTokens(user.id, "signup"); + const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS); + const origin = normalizeOrigin(config.APP_ORIGIN); + await sendEmail({ + to: normalizedEmail, + subject: "Verify your SkyMoney account", + text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`, + html: `

Your SkyMoney verification code is ${code}.

Enter it in the app to finish signing up.

If you prefer, you can also verify at ${origin}/verify.

`, }); - ensureCsrfCookie(reply); - return { ok: true }; + return { ok: true, needsVerification: true }; }); app.post( @@ -546,6 +706,9 @@ app.post( if (!user?.passwordHash) return reply.code(401).send({ ok: false, message: "Invalid credentials" }); const valid = await argon2.verify(user.passwordHash, password); if (!valid) return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + if (!user.emailVerified) { + return reply.code(403).send({ ok: false, code: "EMAIL_NOT_VERIFIED", message: "Email not verified" }); + } await app.ensureUser(user.id); const token = await reply.jwtSign({ sub: user.id }); const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; // Convert to seconds @@ -572,6 +735,142 @@ app.post("/auth/logout", async (_req, reply) => { return { ok: true }; }); +app.post("/auth/verify", async (req, reply) => { + const parsed = VerifyBody.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const normalizedEmail = parsed.data.email.toLowerCase(); + const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); + if (!user) { + return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); + } + const tokenHash = hashToken(parsed.data.code.trim()); + const token = await app.prisma.emailToken.findFirst({ + where: { userId: user.id, type: "signup", tokenHash }, + }); + if (!token) { + return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); + } + if (token.expiresAt < new Date()) { + await app.prisma.emailToken.deleteMany({ where: { userId: user.id, type: "signup" } }); + return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" }); + } + await app.prisma.user.update({ + where: { id: user.id }, + data: { emailVerified: true }, + }); + await clearEmailTokens(user.id, "signup"); + const jwt = await reply.jwtSign({ sub: user.id }); + const maxAge = config.SESSION_TIMEOUT_MINUTES * 60; + reply.setCookie("session", jwt, { + httpOnly: true, + sameSite: "lax", + secure: config.NODE_ENV === "production", + path: "/", + maxAge, + ...(cookieDomain ? { domain: cookieDomain } : {}), + }); + ensureCsrfCookie(reply); + return { ok: true }; +}); + +app.post("/auth/verify/resend", async (req, reply) => { + const parsed = z.object({ email: z.string().email() }).safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const normalizedEmail = parsed.data.email.toLowerCase(); + const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); + if (!user) return reply.code(200).send({ ok: true }); + if (user.emailVerified) return { ok: true, alreadyVerified: true }; + await clearEmailTokens(user.id, "signup"); + const { code } = await issueEmailToken(user.id, "signup", EMAIL_TOKEN_TTL_MS); + const origin = normalizeOrigin(config.APP_ORIGIN); + await sendEmail({ + to: user.email, + subject: "Verify your SkyMoney account", + text: `Your SkyMoney verification code is ${code}. Enter it in the app to finish signing up.`, + html: `

Your SkyMoney verification code is ${code}.

Enter it in the app to finish signing up.

If you prefer, verify at ${origin}/verify.

`, + }); + return { ok: true }; +}); + +app.post("/account/delete-request", async (req, reply) => { + const Body = z.object({ + password: z.string().min(1), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const user = await app.prisma.user.findUnique({ + where: { id: req.userId }, + select: { id: true, email: true, passwordHash: true }, + }); + if (!user?.passwordHash) { + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + const valid = await argon2.verify(user.passwordHash, parsed.data.password); + if (!valid) { + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + await clearEmailTokens(user.id, "delete"); + const { code } = await issueEmailToken(user.id, "delete", DELETE_TOKEN_TTL_MS); + await sendEmail({ + to: user.email, + subject: "Confirm deletion of your SkyMoney account", + text: `Your SkyMoney delete confirmation code is ${code}. Enter it in the app to delete your account.`, + html: `

Your SkyMoney delete confirmation code is ${code}.

Enter it in the app to delete your account.

`, + }); + return { ok: true }; +}); + +app.post("/account/confirm-delete", async (req, reply) => { + const Body = z.object({ + email: z.string().email(), + code: z.string().min(4), + password: z.string().min(1), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const normalizedEmail = parsed.data.email.toLowerCase(); + const user = await app.prisma.user.findUnique({ where: { email: normalizedEmail } }); + if (!user) { + return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); + } + if (!user.passwordHash) { + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + const passwordOk = await argon2.verify(user.passwordHash, parsed.data.password); + if (!passwordOk) { + return reply.code(401).send({ ok: false, message: "Invalid credentials" }); + } + const tokenHash = hashToken(parsed.data.code.trim()); + const token = await app.prisma.emailToken.findFirst({ + where: { userId: user.id, type: "delete", tokenHash }, + }); + if (!token) { + return reply.code(400).send({ ok: false, code: "INVALID_CODE", message: "Invalid code" }); + } + if (token.expiresAt < new Date()) { + await clearEmailTokens(user.id, "delete"); + return reply.code(400).send({ ok: false, code: "CODE_EXPIRED", message: "Code expired" }); + } + await clearEmailTokens(user.id, "delete"); + await app.prisma.user.delete({ where: { id: user.id } }); + reply.clearCookie("session", { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: config.NODE_ENV === "production", + ...(cookieDomain ? { domain: cookieDomain } : {}), + }); + return { ok: true }; +}); + app.post("/auth/refresh", async (req, reply) => { // Generate a new token to extend the session const userId = req.userId; @@ -595,16 +894,55 @@ app.get("/auth/session", async (req, reply) => { } const user = await app.prisma.user.findUnique({ where: { id: req.userId }, - select: { email: true, displayName: true }, + select: { + email: true, + displayName: true, + emailVerified: true, + seenUpdateVersion: true, + }, }); + const noticeVersion = config.UPDATE_NOTICE_VERSION; + const shouldShowNotice = + noticeVersion > 0 && + !!user && + user.emailVerified && + user.seenUpdateVersion < noticeVersion; + return { ok: true, userId: req.userId, email: user?.email ?? null, displayName: user?.displayName ?? null, + emailVerified: user?.emailVerified ?? false, + updateNotice: shouldShowNotice + ? { + version: noticeVersion, + title: config.UPDATE_NOTICE_TITLE, + body: config.UPDATE_NOTICE_BODY, + } + : null, }; }); +app.post("/app/update-notice/ack", mutationRateLimit, async (req, reply) => { + const Body = z.object({ + version: z.coerce.number().int().nonnegative().optional(), + }); + const parsed = Body.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send({ ok: false, message: "Invalid payload" }); + } + const targetVersion = parsed.data.version ?? config.UPDATE_NOTICE_VERSION; + await app.prisma.user.updateMany({ + where: { + id: req.userId, + seenUpdateVersion: { lt: targetVersion }, + }, + data: { seenUpdateVersion: targetVersion }, + }); + return { ok: true }; +}); + app.patch("/me", async (req, reply) => { const Body = z.object({ displayName: z.string().trim().min(1).max(120), diff --git a/bash.exe.stackdump b/bash.exe.stackdump new file mode 100644 index 0000000..df4fb20 --- /dev/null +++ b/bash.exe.stackdump @@ -0,0 +1,55 @@ +Stack trace: +Frame Function Args +0007FFFF4F30 000210060304 (0007FFFF5138, 0007FFFFCE00, 000000000002, 0007FFFFDC10) msys-2.0.dll+0x20304 +000000084002 00021006237D (000000000064, 000700000000, 000000000298, 000000000000) msys-2.0.dll+0x2237D +0007FFFF5640 0002100C1394 (000000000000, 000000000068, 000000000000, 0006FFFFFFB7) msys-2.0.dll+0x81394 +0007FFFF5AE0 0002100C1C19 (0007FFFF5810, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x81C19 +0007FFFF5AE0 00021006A1DE (000000004000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A1DE +0007FFFF5AE0 00021006A4A9 (000000000000, 0007FFFF5B88, 0007FFFF5D64, 0000FFFFFFFF) msys-2.0.dll+0x2A4A9 +0007FFFF5AE0 000210193F2B (000000000000, 0007FFFF5B88, 0007FFFF5D64, 0000FFFFFFFF) msys-2.0.dll+0x153F2B +0007FFFF5AE0 00010042DB65 (000A00000004, 0007FFFF5D74, 00010040DD62, 000000000000) bash.exe+0x2DB65 +0000FFFFFFFF 00010043C4F8 (0000000000C2, 000000000000, 000A001FF4B0, 000A000888B0) bash.exe+0x3C4F8 +000000000070 00010043E6BE (000000000000, 000000000001, 000000000000, 0007FFFF5D70) bash.exe+0x3E6BE +000000000070 000100441B06 (000700000001, 000A00000000, 0007FFFF5E60, 000000000000) bash.exe+0x41B06 +000000000070 000100441D36 (000100000000, 000A00000000, 0007FFFF5F3C, 0007FFFF5F38) bash.exe+0x41D36 +000000000001 000100443783 (00010000001F, 000000000001, 000A001FF310, 000A001FD9D0) bash.exe+0x43783 +0000FFFFFFFF 0001004195CA (00010046F680, 000000000000, 0000FFFFFFFF, 000A001FD9D0) bash.exe+0x195CA +000A001FF8E0 00010041AC6A (00000000001F, 000000000000, 000A001FD500, 000000000000) bash.exe+0x1AC6A +000A001FF8E0 00010041CD1B (000100000000, 000000000000, 000200000000, 000A001FF8E0) bash.exe+0x1CD1B +0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FF8E0) bash.exe+0x179C7 +00000000015B 00010041AC6A (000000000000, 000000000000, 000A001FD500, 000000000000) bash.exe+0x1AC6A +00000000015B 0001004180FB (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x180FB +000A001FD530 00010041C54A (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x1C54A +0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD530) bash.exe+0x179C7 +000A001FD4E0 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A +000A001FD4E0 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD4E0) bash.exe+0x1C50F +0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD4E0) bash.exe+0x179C7 +000A001FD490 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A +000A001FD490 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD490) bash.exe+0x1C50F +0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD490) bash.exe+0x179C7 +000A001FD440 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A +000A001FD440 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD440) bash.exe+0x1C50F +0000FFFFFFFF 0001004179C7 (000000000000, 000000000000, 000000000000, 000A001FD440) bash.exe+0x179C7 +000A001FD3F0 00010041AC6A (000000000000, 000000000000, 000000000000, 000000000000) bash.exe+0x1AC6A +000A001FD3F0 00010041C50F (000000000000, 000000000000, 000000000000, 000A001FD3F0) bash.exe+0x1C50F +End of stack trace (more stack frames may be present) +Loaded modules: +000100400000 bash.exe +7FFF25310000 ntdll.dll +7FFF247D0000 KERNEL32.DLL +7FFF22E40000 KERNELBASE.dll +7FFF24010000 USER32.dll +7FFF22BD0000 win32u.dll +7FFF246C0000 GDI32.dll +000210040000 msys-2.0.dll +7FFF22CA0000 gdi32full.dll +7FFF22C00000 msvcp_win.dll +7FFF22A20000 ucrtbase.dll +7FFF23330000 advapi32.dll +7FFF249F0000 msvcrt.dll +7FFF24A90000 sechost.dll +7FFF24DA0000 RPCRT4.dll +7FFF229A0000 bcrypt.dll +7FFF221F0000 CRYPTBASE.DLL +7FFF23140000 bcryptPrimitives.dll +7FFF25180000 IMM32.DLL diff --git a/docker-compose.yml b/docker-compose.yml index 803bc38..46250fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,19 @@ services: AUTH_DISABLED: ${AUTH_DISABLED:-false} JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me} COOKIE_SECRET: ${COOKIE_SECRET:-dev-cookie-secret-change-me} + APP_ORIGIN: ${APP_ORIGIN:-http://localhost:5173} + UPDATE_NOTICE_VERSION: ${UPDATE_NOTICE_VERSION:-0} + UPDATE_NOTICE_TITLE: ${UPDATE_NOTICE_TITLE:-SkyMoney Updated} + UPDATE_NOTICE_BODY: ${UPDATE_NOTICE_BODY:-We shipped improvements and fixes. Please review the latest changes.} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_REQUIRE_TLS: ${SMTP_REQUIRE_TLS:-true} + SMTP_TLS_REJECT_UNAUTHORIZED: ${SMTP_TLS_REJECT_UNAUTHORIZED:-true} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + EMAIL_FROM: ${EMAIL_FROM:-SkyMoney } + EMAIL_BOUNCE_FROM: ${EMAIL_BOUNCE_FROM:-bounces@skymoneybudget.com} + EMAIL_REPLY_TO: ${EMAIL_REPLY_TO:-support@skymoneybudget.com} depends_on: - postgres diff --git a/web/.env.development b/web/.env.development index fa1a499..a252fe1 100644 --- a/web/.env.development +++ b/web/.env.development @@ -1,2 +1,2 @@ -VITE_API_URL=http://localhost:8080/api +VITE_API_URL=http://localhost:8081 VITE_APP_NAME=SkyMoney diff --git a/web/src/App.tsx b/web/src/App.tsx index 7ce864e..87a10f6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,9 +1,41 @@ -import { Suspense } from "react"; -import { Outlet } from "react-router-dom"; +import { Suspense, useEffect, useState } from "react"; +import { Outlet, useLocation } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import { SessionTimeoutWarning } from "./components/SessionTimeoutWarning"; import NavBar from "./components/NavBar"; +import { useAuthSession } from "./hooks/useAuthSession"; +import { http } from "./api/http"; +import UpdateNoticeModal from "./components/UpdateNoticeModal"; export default function App() { + const location = useLocation(); + const qc = useQueryClient(); + const session = useAuthSession({ retry: false }); + const [dismissedVersion, setDismissedVersion] = useState(null); + + const notice = session.data?.updateNotice; + const isPublicRoute = + location.pathname.startsWith("/login") || + location.pathname.startsWith("/register") || + location.pathname.startsWith("/verify") || + location.pathname.startsWith("/beta"); + + const showUpdateModal = !isPublicRoute && !!notice && dismissedVersion !== notice.version; + + useEffect(() => { + setDismissedVersion(null); + }, [session.data?.userId]); + + const acknowledgeNotice = async () => { + if (!notice) return; + await http("/app/update-notice/ack", { + method: "POST", + body: { version: notice.version }, + }); + setDismissedVersion(notice.version); + await qc.invalidateQueries({ queryKey: ["auth", "session"] }); + }; + return ( <> @@ -13,6 +45,13 @@ export default function App() { + {showUpdateModal && notice ? ( + + ) : null} ); } diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx index 3257816..1a49b83 100644 --- a/web/src/components/RequireAuth.tsx +++ b/web/src/components/RequireAuth.tsx @@ -31,5 +31,12 @@ export function RequireAuth({ children }: Props) { ); } + if (session.data && !session.data.emailVerified) { + const next = encodeURIComponent( + `${location.pathname}${location.search}`.replace(/^$/, "/") + ); + return ; + } + return <>{children}; } diff --git a/web/src/components/UpdateNoticeModal.tsx b/web/src/components/UpdateNoticeModal.tsx new file mode 100644 index 0000000..7a6983f --- /dev/null +++ b/web/src/components/UpdateNoticeModal.tsx @@ -0,0 +1,21 @@ +type Props = { + title: string; + body: string; + onAcknowledge: () => Promise | void; +}; + +export default function UpdateNoticeModal({ title, body, onAcknowledge }: Props) { + return ( +
+
+

{title}

+

{body}

+
+ +
+
+
+ ); +} diff --git a/web/src/hooks/useAuthSession.ts b/web/src/hooks/useAuthSession.ts index 2535003..654949b 100644 --- a/web/src/hooks/useAuthSession.ts +++ b/web/src/hooks/useAuthSession.ts @@ -1,7 +1,18 @@ import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; import { http } from "../api/http"; -type SessionResponse = { ok: true; userId: string; email: string | null; displayName: string | null }; +type SessionResponse = { + ok: true; + userId: string; + email: string | null; + displayName: string | null; + emailVerified: boolean; + updateNotice: { + version: number; + title: string; + body: string; + } | null; +}; type Options = Omit, "queryKey" | "queryFn">; diff --git a/web/src/main.tsx b/web/src/main.tsx index 2ee19e4..e9a26a2 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -54,6 +54,7 @@ const OnboardingPage = lazy(() => import("./pages/OnboardingPage")); const LoginPage = lazy(() => import("./pages/LoginPage")); const RegisterPage = lazy(() => import("./pages/RegisterPage")); const BetaAccessPage = lazy(() => import("./pages/BetaAccessPage")); +const VerifyPage = lazy(() => import("./pages/VerifyPage")); const router = createBrowserRouter( createRoutesFromElements( @@ -70,6 +71,7 @@ const router = createBrowserRouter( {/* Public */} } /> } /> + } /> {/* Protected onboarding */} ("/auth/register", { + const result = await http<{ ok: true; needsVerification?: boolean }>( + "/auth/register", + { method: "POST", body: { email, password }, skipAuthRedirect: true, - }); + } + ); qc.clear(); - navigate(next || "/", { replace: true }); + if (result.needsVerification) { + navigate(`/verify?email=${encodeURIComponent(email)}&next=${encodeURIComponent(next || "/")}`, { + replace: true, + }); + } else { + navigate(next || "/", { replace: true }); + } } catch (err) { const status = (err as { status?: number })?.status; const message = diff --git a/web/src/pages/VerifyPage.tsx b/web/src/pages/VerifyPage.tsx new file mode 100644 index 0000000..b2cd9c3 --- /dev/null +++ b/web/src/pages/VerifyPage.tsx @@ -0,0 +1,126 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { http } from "../api/http"; +import { useToast } from "../components/Toast"; + +function useQueryParams() { + const location = useLocation(); + return useMemo(() => new URLSearchParams(location.search), [location.search]); +} + +export default function VerifyPage() { + const navigate = useNavigate(); + const qc = useQueryClient(); + const params = useQueryParams(); + const { push } = useToast(); + + const [email, setEmail] = useState(params.get("email") || ""); + const [code, setCode] = useState(""); + const [pending, setPending] = useState(false); + const [resendPending, setResendPending] = useState(false); + const [error, setError] = useState(null); + + const next = params.get("next") || "/"; + + useEffect(() => { + const prefEmail = params.get("email"); + if (prefEmail) setEmail(prefEmail); + }, [params]); + + const emailError = !email.trim() ? "Email is required." : ""; + const codeError = !code.trim() ? "Verification code is required." : ""; + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + if (emailError || codeError) { + setError(emailError || codeError); + return; + } + setPending(true); + try { + await http("/auth/verify", { + method: "POST", + body: { email, code }, + skipAuthRedirect: true, + }); + await qc.invalidateQueries({ queryKey: ["auth", "session"] }); + navigate(next, { replace: true }); + } catch (err: any) { + const message = err?.data?.message || err?.message || "Invalid code."; + setError(message); + } finally { + setPending(false); + } + } + + async function handleResend() { + if (!email.trim()) { + setError("Enter your email first."); + return; + } + setResendPending(true); + try { + await http("/auth/verify/resend", { + method: "POST", + body: { email }, + skipAuthRedirect: true, + }); + push("ok", "Verification code sent."); + } catch (err: any) { + push("err", err?.message ?? "Unable to resend code."); + } finally { + setResendPending(false); + } + } + + return ( +
+
+

Verify your email

+

+ Enter the verification code we sent to your inbox to activate your account. +

+ {error &&
{error}
} +
+ + + +
+ +

+ Need to log in? Sign in +

+
+
+ ); +} diff --git a/web/src/pages/settings/AccountSettings.tsx b/web/src/pages/settings/AccountSettings.tsx index f789ae3..a6e88cd 100644 --- a/web/src/pages/settings/AccountSettings.tsx +++ b/web/src/pages/settings/AccountSettings.tsx @@ -33,6 +33,10 @@ export default function AccountSettings() { const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false); const [fixedExpensePercentage, setFixedExpensePercentage] = useState(40); const [isUpdatingConservatism, setIsUpdatingConservatism] = useState(false); + const [deletePassword, setDeletePassword] = useState(""); + const [deleteCode, setDeleteCode] = useState(""); + const [isDeleteRequesting, setIsDeleteRequesting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const browserTimezone = useMemo(() => getBrowserTimezone(), []); const timezoneOptions = useMemo(() => { @@ -237,6 +241,46 @@ export default function AccountSettings() { } }; + const handleRequestDelete = async () => { + if (!deletePassword.trim()) { + push("err", "Enter your password to request a deletion code."); + return; + } + setIsDeleteRequesting(true); + try { + await http("/account/delete-request", { + method: "POST", + body: { password: deletePassword }, + }); + push("ok", "Delete confirmation code sent to your email."); + } catch (error: any) { + push("err", error?.message ?? "Failed to send delete code"); + } finally { + setIsDeleteRequesting(false); + } + }; + + const handleConfirmDelete = async () => { + if (!deleteCode.trim()) { + push("err", "Enter the delete confirmation code."); + return; + } + setIsDeleting(true); + try { + await http("/account/confirm-delete", { + method: "POST", + body: { email: email.trim(), code: deleteCode.trim(), password: deletePassword }, + skipAuthRedirect: true, + }); + qc.clear(); + window.location.replace("/login"); + } catch (error: any) { + push("err", error?.message ?? "Failed to delete account"); + } finally { + setIsDeleting(false); + } + }; + return (
{/* Profile Information */} @@ -495,18 +539,50 @@ export default function AccountSettings() {

Delete Account

-

Permanently delete your account and all associated data. This action cannot be undone.

+

+ Permanently delete your account and all associated data. This action cannot be undone. +

+
+
+ + + +
-