feat: email verification + delete confirmation + smtp/cors/prod hardening
This commit is contained in:
21
.env
21
.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 <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
|
||||
13
.env.example
13
.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 <support@skymoneybudget.com>
|
||||
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
|
||||
EMAIL_REPLY_TO=support@skymoneybudget.com
|
||||
|
||||
35
api/package-lock.json
generated
35
api/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "User"
|
||||
ADD COLUMN IF NOT EXISTS "seenUpdateVersion" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,15 +505,20 @@ 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);
|
||||
// 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);
|
||||
cb(null, allow.includes(origin));
|
||||
const normalized = normalizeOrigin(origin);
|
||||
cb(null, configuredOrigins.includes(normalized));
|
||||
};
|
||||
})(),
|
||||
credentials: true,
|
||||
@@ -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
55
bash.exe.stackdump
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8080/api
|
||||
VITE_API_URL=http://localhost:8081
|
||||
VITE_APP_NAME=SkyMoney
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
21
web/src/components/UpdateNoticeModal.tsx
Normal file
21
web/src/components/UpdateNoticeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,7 +60,15 @@ 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."
|
||||
|
||||
@@ -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();
|
||||
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 =
|
||||
|
||||
126
web/src/pages/VerifyPage.tsx
Normal file
126
web/src/pages/VerifyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={() => {
|
||||
push("ok", "Account deletion requires email confirmation");
|
||||
}}
|
||||
onClick={handleRequestDelete}
|
||||
disabled={isDeleteRequesting}
|
||||
>
|
||||
Delete Account
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user