feat: email verification + delete confirmation + smtp/cors/prod hardening #1

Merged
Joders merged 1 commits from feature/email-verification-prod-hardening into main 2026-02-09 20:47:55 +00:00
22 changed files with 896 additions and 58 deletions

21
.env
View File

@@ -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 <no-reply@skymoneybudget.com>
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

View File

@@ -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 <support@skymoneybudget.com>
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
EMAIL_REPLY_TO=support@skymoneybudget.com

35
api/package-lock.json generated
View File

@@ -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": {

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "User"
ADD COLUMN IF NOT EXISTS "seenUpdateVersion" INTEGER NOT NULL DEFAULT 0;

View File

@@ -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])
}

View File

@@ -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 <noreply@skymoneybudget.com>"),
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;

View File

@@ -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}
<hr style="margin:20px 0;border:none;border-top:1px solid #e5e7eb;"/>
<p style="margin:0;color:#6b7280;font-size:12px;line-height:1.5;">
SkyMoney Budget<br/>
<a href="https://skymoneybudget.com">skymoneybudget.com</a><br/>
Need help? <a href="mailto:support@skymoneybudget.com">support@skymoneybudget.com</a>
</p>`;
}
async function sendEmail({
to,
subject,
text,
html,
}: {
to: string;
subject: string;
text: string;
html?: string;
}) {
const finalText = buildEmailTextBody(text);
const finalHtml = buildEmailHtmlBody(html ?? `<p>${text}</p>`);
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: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, you can also verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
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: `<p>Your SkyMoney verification code is <strong>${code}</strong>.</p><p>Enter it in the app to finish signing up.</p><p>If you prefer, verify at <a href=\"${origin}/verify\">${origin}/verify</a>.</p>`,
});
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: `<p>Your SkyMoney delete confirmation code is <strong>${code}</strong>.</p><p>Enter it in the app to delete your account.</p>`,
});
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),

55
bash.exe.stackdump Normal file
View File

@@ -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

View File

@@ -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 <noreply@skymoneybudget.com>}
EMAIL_BOUNCE_FROM: ${EMAIL_BOUNCE_FROM:-bounces@skymoneybudget.com}
EMAIL_REPLY_TO: ${EMAIL_REPLY_TO:-support@skymoneybudget.com}
depends_on:
- postgres

View File

@@ -1,2 +1,2 @@
VITE_API_URL=http://localhost:8080/api
VITE_API_URL=http://localhost:8081
VITE_APP_NAME=SkyMoney

View File

@@ -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<number | null>(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 (
<>
<SessionTimeoutWarning />
@@ -13,6 +45,13 @@ export default function App() {
<Outlet />
</Suspense>
</main>
{showUpdateModal && notice ? (
<UpdateNoticeModal
title={notice.title}
body={notice.body}
onAcknowledge={acknowledgeNotice}
/>
) : null}
</>
);
}

View File

@@ -31,5 +31,12 @@ export function RequireAuth({ children }: Props) {
);
}
if (session.data && !session.data.emailVerified) {
const next = encodeURIComponent(
`${location.pathname}${location.search}`.replace(/^$/, "/")
);
return <Navigate to={`/verify?next=${next}`} replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,21 @@
type Props = {
title: string;
body: string;
onAcknowledge: () => Promise<void> | void;
};
export default function UpdateNoticeModal({ title, body, onAcknowledge }: Props) {
return (
<div className="fixed inset-0 z-[120] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div className="card max-w-lg w-full p-6">
<h2 className="text-xl font-semibold mb-2">{title}</h2>
<p className="muted whitespace-pre-wrap mb-6">{body}</p>
<div className="flex justify-end">
<button className="btn" onClick={() => void onAcknowledge()}>
Got it
</button>
</div>
</div>
</div>
);
}

View File

@@ -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<UseQueryOptions<SessionResponse, Error>, "queryKey" | "queryFn">;

View File

@@ -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 */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/verify" element={<VerifyPage />} />
{/* Protected onboarding */}
<Route

View File

@@ -60,11 +60,19 @@ export default function LoginPage() {
qc.clear();
navigate(next || "/", { replace: true });
} catch (err) {
const status = (err as { status?: number })?.status;
const status = (err as { status?: number; code?: string })?.status;
const code = (err as { code?: string })?.code;
if (status === 403 && code === "EMAIL_NOT_VERIFIED") {
navigate(
`/verify?email=${encodeURIComponent(email)}&next=${encodeURIComponent(next || "/")}`,
{ replace: true }
);
return;
}
const message =
status === 401
? "Email or password is incorrect."
: status === 400
: status === 400
? "Enter a valid email and password."
: err instanceof Error
? err.message

View File

@@ -65,13 +65,22 @@ export default function RegisterPage() {
return;
}
try {
await http<{ ok: true }>("/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 =

View File

@@ -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<string | null>(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 (
<div className="flex justify-center py-16 px-4">
<div className="card w-full max-w-md">
<h1 className="section-title mb-2">Verify your email</h1>
<p className="muted mb-6">
Enter the verification code we sent to your inbox to activate your account.
</p>
{error && <div className="alert alert-error mb-4">{error}</div>}
<form className="stack gap-4" onSubmit={handleSubmit}>
<label className="stack gap-1">
<span className="text-sm font-medium">Email</span>
<input
className="input"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
</label>
<label className="stack gap-1">
<span className="text-sm font-medium">Verification code</span>
<input
className="input"
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6-digit code"
/>
</label>
<button className="btn primary" type="submit" disabled={pending}>
{pending ? "Verifying..." : "Verify email"}
</button>
</form>
<button
className="btn btn-outline w-full mt-3"
type="button"
onClick={handleResend}
disabled={resendPending}
>
{resendPending ? "Sending..." : "Resend code"}
</button>
<p className="muted text-sm mt-6 text-center">
Need to log in? <Link className="link" to="/login">Sign in</Link>
</p>
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
{/* Profile Information */}
@@ -495,18 +539,50 @@ export default function AccountSettings() {
<div className="settings-section settings-danger-section" style={{ marginBottom: 0 }}>
<div className="settings-section-header" style={{ borderBottom: "none", paddingBottom: 0, marginBottom: "0.5rem" }}>
<h3 className="settings-section-title">Delete Account</h3>
<p className="settings-section-desc">Permanently delete your account and all associated data. This action cannot be undone.</p>
<p className="settings-section-desc">
Permanently delete your account and all associated data. This action cannot be undone.
</p>
</div>
<div className="stack gap-3">
<label className="stack gap-1">
<span className="text-sm font-medium">Confirm password</span>
<input
className="input"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder="Enter your password"
/>
</label>
<button
type="button"
className="btn btn-outline"
style={{ borderColor: "var(--color-danger, #ef4444)", color: "var(--color-danger, #ef4444)" }}
onClick={handleRequestDelete}
disabled={isDeleteRequesting}
>
{isDeleteRequesting ? "Sending code..." : "Send delete code"}
</button>
<label className="stack gap-1">
<span className="text-sm font-medium">Delete confirmation code</span>
<input
className="input"
type="text"
value={deleteCode}
onChange={(e) => setDeleteCode(e.target.value)}
placeholder="Enter the code from your email"
/>
</label>
<button
type="button"
className="btn btn-outline"
style={{ borderColor: "var(--color-danger, #ef4444)", color: "var(--color-danger, #ef4444)" }}
onClick={handleConfirmDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete Account"}
</button>
</div>
<button
type="button"
className="btn btn-outline"
style={{ borderColor: "var(--color-danger, #ef4444)", color: "var(--color-danger, #ef4444)" }}
onClick={() => {
push("ok", "Account deletion requires email confirmation");
}}
>
Delete Account
</button>
</div>
</div>
</section>