From 43480e7752666ba642e658fe3a8b544a4193e73b Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sun, 1 Mar 2026 21:07:12 +0000 Subject: [PATCH 1/5] feat(api): add email verification tokens, SMTP services, and auth gating --- package-lock.json | 31 ++- .../api/drizzle/0016_email_verification.sql | 22 ++ packages/api/drizzle/meta/_journal.json | 7 + packages/api/package.json | 24 +- .../src/controllers/admin/emailTemplates.ts | 96 ++++++++ .../api/src/controllers/admin/emailTest.ts | 41 ++++ .../src/controllers/admin/settingsUpdate.ts | 57 ++++- .../api/src/controllers/install/getInstall.ts | 29 ++- .../install/postInstallComplete.ts | 35 +++ .../user/emailVerificationResend.ts | 42 ++++ .../user/emailVerificationVerify.ts | 37 +++ .../user/opaqueLoginFinish.test.ts | 71 +++++- .../src/controllers/user/opaqueLoginFinish.ts | 12 + .../controllers/user/opaqueRegisterFinish.ts | 17 +- .../controllers/user/opaqueRegisterStart.ts | 2 + .../controllers/user/profileEmailUpdate.ts | 54 +++++ packages/api/src/db/schema.ts | 34 +++ packages/api/src/http/openapi.ts | 14 ++ packages/api/src/http/routers/adminRouter.ts | 12 + packages/api/src/http/routers/userRouter.ts | 12 + .../models/emailVerificationTokens.test.ts | 93 ++++++++ .../api/src/models/emailVerificationTokens.ts | 90 +++++++ packages/api/src/models/registration.ts | 34 ++- packages/api/src/services/email.test.ts | 131 +++++++++++ packages/api/src/services/email.ts | 134 +++++++++++ packages/api/src/services/emailTemplates.ts | 123 ++++++++++ .../src/services/emailVerification.test.ts | 57 +++++ .../api/src/services/emailVerification.ts | 221 ++++++++++++++++++ packages/api/src/services/settings.ts | 109 ++++++++- 29 files changed, 1602 insertions(+), 39 deletions(-) create mode 100644 packages/api/drizzle/0016_email_verification.sql create mode 100644 packages/api/src/controllers/admin/emailTemplates.ts create mode 100644 packages/api/src/controllers/admin/emailTest.ts create mode 100644 packages/api/src/controllers/user/emailVerificationResend.ts create mode 100644 packages/api/src/controllers/user/emailVerificationVerify.ts create mode 100644 packages/api/src/controllers/user/profileEmailUpdate.ts create mode 100644 packages/api/src/models/emailVerificationTokens.test.ts create mode 100644 packages/api/src/models/emailVerificationTokens.ts create mode 100644 packages/api/src/services/email.test.ts create mode 100644 packages/api/src/services/email.ts create mode 100644 packages/api/src/services/emailTemplates.ts create mode 100644 packages/api/src/services/emailVerification.test.ts create mode 100644 packages/api/src/services/emailVerification.ts diff --git a/package-lock.json b/package-lock.json index 10a9f44..f65ede8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "semantic-release": "^25.0.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=24.0.0" } }, "node_modules/@acemir/cssom": { @@ -5726,6 +5726,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -11656,6 +11666,15 @@ "dev": true, "license": "MIT" }, + "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/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -18278,7 +18297,7 @@ }, "packages/api": { "name": "@DarkAuth/api", - "version": "1.0.0", + "version": "0.0.0", "license": "AGPL-3.0", "dependencies": { "@electric-sql/pglite": "^0.3.15", @@ -18287,6 +18306,7 @@ "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", "jose": "^6.1.3", + "nodemailer": "^7.0.13", "opaque-ts": "file:../opaque-ts", "pg": "^8.17.1", "pino": "^10.2.0", @@ -18296,13 +18316,14 @@ "devDependencies": { "@biomejs/biome": "^2.3.11", "@types/node": "^25.0.9", + "@types/nodemailer": "^7.0.3", "@types/pg": "^8.16.0", "drizzle-kit": "^0.31.8", "pino-pretty": "^13.1.3", "typescript": "^5.9.3" }, "engines": { - "node": ">=20.0.0" + "node": ">=24.0.0" } }, "packages/brochureware": { @@ -18388,7 +18409,7 @@ }, "packages/darkauth-client": { "name": "@darkauth/client", - "version": "0.2.1", + "version": "1.4.4", "license": "MIT", "dependencies": { "jose": "^6.1.3" @@ -18400,7 +18421,7 @@ }, "packages/demo-app": { "name": "@DarkAuth/demo-app", - "version": "0.1.0", + "version": "0.0.0", "license": "MIT", "dependencies": { "@automerge/automerge": "^3.2.2", diff --git a/packages/api/drizzle/0016_email_verification.sql b/packages/api/drizzle/0016_email_verification.sql new file mode 100644 index 0000000..cec4cfe --- /dev/null +++ b/packages/api/drizzle/0016_email_verification.sql @@ -0,0 +1,22 @@ +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "email_verified_at" timestamp; +--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "pending_email" text; +--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "pending_email_set_at" timestamp; +--> statement-breakpoint + +CREATE TABLE IF NOT EXISTS "email_verification_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "user_sub" text NOT NULL REFERENCES "users"("sub") ON DELETE cascade, + "purpose" text NOT NULL, + "target_email" text NOT NULL, + "token_hash" text NOT NULL UNIQUE, + "expires_at" timestamp NOT NULL, + "consumed_at" timestamp, + "created_at" timestamp NOT NULL DEFAULT now() +); +--> statement-breakpoint + +CREATE INDEX IF NOT EXISTS "email_verification_tokens_user_purpose_idx" ON "email_verification_tokens" ("user_sub", "purpose"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "email_verification_tokens_expires_at_idx" ON "email_verification_tokens" ("expires_at"); diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json index 75f0cb9..ce93c43 100644 --- a/packages/api/drizzle/meta/_journal.json +++ b/packages/api/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1772393344715, "tag": "0015_org_force_otp", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1772445600000, + "tag": "0016_email_verification", + "breakpoints": true } ] } diff --git a/packages/api/package.json b/packages/api/package.json index 1e69623..c79c57a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -5,16 +5,16 @@ "description": "DarkAuth API server with OPAQUE authentication and OIDC compatibility", "type": "module", "main": "src/main.ts", - "scripts": { - "dev": "node --watch src/main.ts", - "build": "tsc --noEmit", - "start": "node src/main.ts", - "test": "node --test src/**/*.test.ts", - "db:push": "drizzle-kit push", + "scripts": { + "dev": "node --env-file-if-exists=../../.env --watch src/main.ts", + "build": "tsc --noEmit", + "start": "node --env-file-if-exists=../../.env src/main.ts", + "test": "node --env-file-if-exists=../../.env --test src/**/*.test.ts", + "db:push": "drizzle-kit push", "db:generate": "drizzle-kit generate", - "db:migrate": "node src/db/migrate.ts", - "db:wipe": "node scripts/wipe.ts", - "install:script": "node scripts/install.ts", + "db:migrate": "node --env-file-if-exists=../../.env src/db/migrate.ts", + "db:wipe": "node --env-file-if-exists=../../.env scripts/wipe.ts", + "install:script": "node --env-file-if-exists=../../.env scripts/install.ts", "typecheck": "tsc --noEmit", "format": "biome format --write .", "lint": "biome lint .", @@ -23,20 +23,22 @@ "tidy": "biome check --write . && biome lint --write ." }, "dependencies": { + "@electric-sql/pglite": "^0.3.15", "@peculiar/webcrypto": "^1.5.0", "argon2": "^0.44.0", "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", "jose": "^6.1.3", + "nodemailer": "^7.0.13", "opaque-ts": "file:../opaque-ts", - "pg": "^8.17.1", + "pg": "^8.17.1", "pino": "^10.2.0", - "@electric-sql/pglite": "^0.3.15", "yaml": "^2.8.2", "zod": "^4.3.5" }, "devDependencies": { "@biomejs/biome": "^2.3.11", + "@types/nodemailer": "^7.0.3", "@types/node": "^25.0.9", "@types/pg": "^8.16.0", "drizzle-kit": "^0.31.8", diff --git a/packages/api/src/controllers/admin/emailTemplates.ts b/packages/api/src/controllers/admin/emailTemplates.ts new file mode 100644 index 0000000..d84aa6d --- /dev/null +++ b/packages/api/src/controllers/admin/emailTemplates.ts @@ -0,0 +1,96 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { z } from "zod/v4"; +import { ForbiddenError } from "../../errors.ts"; +import { genericErrors } from "../../http/openapi-helpers.ts"; +import { + type EmailTemplate, + type EmailTemplateKey, + getAllEmailTemplates, + updateEmailTemplate, +} from "../../services/emailTemplates.ts"; +import { requireSession } from "../../services/sessions.ts"; +import type { Context, ControllerSchema } from "../../types.ts"; +import { parseJsonSafely, readBody, sendJson } from "../../utils/http.ts"; + +const TemplateSchema = z.object({ + subject: z.string(), + text: z.string(), + html: z.string(), +}); + +const KeySchema = z.enum([ + "signup_verification", + "verification_resend_confirmation", + "email_change_verification", + "password_recovery", + "admin_test_email", +]); + +const UpdateSchema = z.object({ + key: KeySchema, + template: TemplateSchema, +}); + +export async function getAdminEmailTemplates( + context: Context, + request: IncomingMessage, + response: ServerResponse +): Promise { + const session = await requireSession(context, request, true); + if (!session.adminRole) { + throw new ForbiddenError("Admin access required"); + } + + const templates = await getAllEmailTemplates(context); + sendJson(response, 200, { templates }); +} + +export async function putAdminEmailTemplate( + context: Context, + request: IncomingMessage, + response: ServerResponse +): Promise { + const session = await requireSession(context, request, true); + if (!session.adminRole || session.adminRole === "read") { + throw new ForbiddenError("Write access required"); + } + + const body = await readBody(request); + const raw = parseJsonSafely(body); + const parsed = UpdateSchema.parse(raw); + + await updateEmailTemplate( + context, + parsed.key as EmailTemplateKey, + parsed.template as EmailTemplate + ); + sendJson(response, 200, { success: true, key: parsed.key }); +} + +export const getSchema = { + method: "GET", + path: "/admin/email-templates", + tags: ["Settings"], + summary: "List email templates", + responses: { + 200: { description: "OK" }, + ...genericErrors, + }, +} as const satisfies ControllerSchema; + +export const putSchema = { + method: "PUT", + path: "/admin/email-templates", + tags: ["Settings"], + summary: "Update email template", + body: { + description: "", + required: true, + contentType: "application/json", + schema: UpdateSchema, + }, + responses: { + 200: { description: "OK" }, + ...genericErrors, + }, +} as const satisfies ControllerSchema; diff --git a/packages/api/src/controllers/admin/emailTest.ts b/packages/api/src/controllers/admin/emailTest.ts new file mode 100644 index 0000000..7ac759c --- /dev/null +++ b/packages/api/src/controllers/admin/emailTest.ts @@ -0,0 +1,41 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { z } from "zod/v4"; +import { ForbiddenError, ValidationError } from "../../errors.ts"; +import { genericErrors } from "../../http/openapi-helpers.ts"; +import { sendTestEmailToCurrentAdmin } from "../../services/email.ts"; +import { requireSession } from "../../services/sessions.ts"; +import type { Context, ControllerSchema } from "../../types.ts"; +import { sendJson } from "../../utils/http.ts"; + +export async function postAdminEmailTest( + context: Context, + request: IncomingMessage, + response: ServerResponse +): Promise { + const session = await requireSession(context, request, true); + if (!session.adminRole || session.adminRole === "read") { + throw new ForbiddenError("Write access required"); + } + if (!session.email) { + throw new ValidationError("Admin email is missing from session"); + } + + await sendTestEmailToCurrentAdmin(context, session.email); + sendJson(response, 200, { success: true }); +} + +const Resp = z.object({ success: z.boolean() }); + +export const schema = { + method: "POST", + path: "/admin/settings/email/test", + tags: ["Settings"], + summary: "Send SMTP test email", + responses: { + 200: { + description: "OK", + content: { "application/json": { schema: Resp } }, + }, + ...genericErrors, + }, +} as const satisfies ControllerSchema; diff --git a/packages/api/src/controllers/admin/settingsUpdate.ts b/packages/api/src/controllers/admin/settingsUpdate.ts index c5c8c89..3efb980 100644 --- a/packages/api/src/controllers/admin/settingsUpdate.ts +++ b/packages/api/src/controllers/admin/settingsUpdate.ts @@ -25,6 +25,7 @@ const ALLOWED_SETTINGS = [ "public_origin", "rp_id", "users", + "email", "ui_user", "ui_admin", "ui_demo", @@ -70,13 +71,20 @@ async function updateSettingsHandler( validateRateLimitsSettings(data.value); } else if (data.key === "security") { validateSecuritySettings(data.value); + } else if (data.key === "email.smtp.port") { + validateSmtpPort(data.value); + } else if (data.key === "email.verification.token_ttl_minutes") { + validateVerificationTtl(data.value); + } else if (data.key === "email.smtp.enabled") { + await validateSmtpEnable(context, data.value); } // Get the old value for audit logging const oldValue = await getSetting(context, data.key); // Update the setting - await setSetting(context, data.key, data.value); + const secure = data.key === "email.smtp.password"; + await setSetting(context, data.key, data.value, secure); if ( data.key === "rate_limits" || @@ -96,6 +104,53 @@ async function updateSettingsHandler( }); } +function validateSmtpPort(value: unknown): void { + if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) { + throw new ValidationError("SMTP port must be between 1 and 65535"); + } +} + +function validateVerificationTtl(value: unknown): void { + if (typeof value !== "number" || !Number.isInteger(value) || value < 5 || value > 10080) { + throw new ValidationError("Verification token TTL must be between 5 and 10080 minutes"); + } +} + +async function validateSmtpEnable(context: Context, value: unknown): Promise { + if (typeof value !== "boolean") { + throw new ValidationError("SMTP enabled must be a boolean"); + } + if (!value) return; + + const [from, transport, host, port, user, password] = await Promise.all([ + getSetting(context, "email.from"), + getSetting(context, "email.transport"), + getSetting(context, "email.smtp.host"), + getSetting(context, "email.smtp.port"), + getSetting(context, "email.smtp.user"), + getSetting(context, "email.smtp.password"), + ]); + + const fromText = typeof from === "string" ? from.trim() : ""; + const transportText = typeof transport === "string" ? transport.trim() : ""; + const hostText = typeof host === "string" ? host.trim() : ""; + const userText = typeof user === "string" ? user.trim() : ""; + const passwordText = typeof password === "string" ? password : ""; + const portNumber = typeof port === "number" ? port : 0; + if ( + !fromText || + !transportText || + !hostText || + !userText || + !passwordText || + !Number.isInteger(portNumber) || + portNumber < 1 || + portNumber > 65535 + ) { + throw new ValidationError("SMTP cannot be enabled until all required SMTP fields are set"); + } +} + interface RateLimitConfigDb { window_minutes?: number; max_requests?: number; diff --git a/packages/api/src/controllers/install/getInstall.ts b/packages/api/src/controllers/install/getInstall.ts index 33b8d84..b9f6c18 100644 --- a/packages/api/src/controllers/install/getInstall.ts +++ b/packages/api/src/controllers/install/getInstall.ts @@ -15,6 +15,16 @@ const InstallResponseSchema = z.object({ ok: z.boolean(), hasKek: z.boolean(), dbReady: z.boolean(), + prefill: z.object({ + email: z.object({ + from: z.string().optional(), + transport: z.string().optional(), + smtpHost: z.string().optional(), + smtpPort: z.number().optional(), + smtpUser: z.string().optional(), + smtpPassword: z.string().optional(), + }), + }), }); export async function getInstall( @@ -45,7 +55,24 @@ export async function getInstall( const hasKek = typeof context.config.kekPassphrase === "string" && context.config.kekPassphrase.length > 0; const dbReady = Boolean(context.services.install?.tempDb); - sendJson(response, 200, { ok: true, hasKek, dbReady }); + const smtpPortRaw = process.env.EMAIL_SMTP_PORT; + const smtpPort = + smtpPortRaw && Number.isFinite(Number(smtpPortRaw)) ? Number(smtpPortRaw) : undefined; + sendJson(response, 200, { + ok: true, + hasKek, + dbReady, + prefill: { + email: { + from: process.env.EMAIL_FROM, + transport: process.env.EMAIL_TRANSPORT, + smtpHost: process.env.EMAIL_SMTP_HOST, + smtpPort, + smtpUser: process.env.EMAIL_SMTP_USER, + smtpPassword: process.env.EMAIL_SMTP_PASSWORD, + }, + }, + }); } export const schema = { diff --git a/packages/api/src/controllers/install/postInstallComplete.ts b/packages/api/src/controllers/install/postInstallComplete.ts index 15758e1..271486c 100644 --- a/packages/api/src/controllers/install/postInstallComplete.ts +++ b/packages/api/src/controllers/install/postInstallComplete.ts @@ -26,6 +26,16 @@ const InstallCompleteRequestSchema = z.object({ adminEmail: z.string().email(), adminName: z.string(), selfRegistrationEnabled: z.boolean().optional(), + email: z + .object({ + from: z.string().optional(), + transport: z.string().optional(), + smtpHost: z.string().optional(), + smtpPort: z.number().int().optional(), + smtpUser: z.string().optional(), + smtpPassword: z.string().optional(), + }) + .optional(), }); const InstallCompleteResponseSchema = z.object({ @@ -117,6 +127,31 @@ async function _postInstallComplete( "users.self_registration_enabled", data.selfRegistrationEnabled === true ); + const smtpFrom = data.email?.from?.trim() || ""; + const smtpTransport = data.email?.transport?.trim() || "smtp"; + const smtpHost = data.email?.smtpHost?.trim() || ""; + const smtpPort = data.email?.smtpPort; + const smtpUser = data.email?.smtpUser?.trim() || ""; + const smtpPassword = data.email?.smtpPassword || ""; + const smtpDetailsProvided = + smtpFrom.length > 0 && + smtpTransport.length > 0 && + smtpHost.length > 0 && + typeof smtpPort === "number" && + smtpPort >= 1 && + smtpPort <= 65535 && + smtpUser.length > 0 && + smtpPassword.length > 0; + const smtpEnabled = smtpDetailsProvided; + + await setSetting(installCtx, "email.from", smtpFrom); + await setSetting(installCtx, "email.transport", smtpTransport); + await setSetting(installCtx, "email.smtp.host", smtpHost); + await setSetting(installCtx, "email.smtp.port", smtpPort && smtpPort > 0 ? smtpPort : 587); + await setSetting(installCtx, "email.smtp.user", smtpUser); + await setSetting(installCtx, "email.smtp.password", smtpPassword, true); + await setSetting(installCtx, "email.smtp.enabled", smtpEnabled); + await setSetting(installCtx, "users.require_email_verification", smtpEnabled); await (await import("../../models/install.ts")).writeKdfSetting(installCtx, kdfParams); context.logger.debug("[install:post] generating signing keys"); diff --git a/packages/api/src/controllers/user/emailVerificationResend.ts b/packages/api/src/controllers/user/emailVerificationResend.ts new file mode 100644 index 0000000..38e2092 --- /dev/null +++ b/packages/api/src/controllers/user/emailVerificationResend.ts @@ -0,0 +1,42 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { z } from "zod/v4"; +import { genericErrors } from "../../http/openapi-helpers.ts"; +import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.ts"; +import { resendSignupVerificationByEmail } from "../../services/emailVerification.ts"; +import type { Context, ControllerSchema } from "../../types.ts"; +import { parseJsonSafely, sendJson } from "../../utils/http.ts"; + +const BodySchema = z.object({ email: z.string().email() }); + +export const postEmailVerificationResend = withRateLimit("auth", (body) => + body && typeof body === "object" && "email" in body + ? (body as { email?: string }).email + : undefined +)(async (context: Context, request: IncomingMessage, response: ServerResponse): Promise => { + const body = await getCachedBody(request); + const raw = parseJsonSafely(body); + const parsed = BodySchema.parse(raw); + + await resendSignupVerificationByEmail(context, parsed.email.trim().toLowerCase()); + sendJson(response, 200, { + success: true, + message: "If your account is pending verification, a new email has been sent", + }); +}); + +export const schema = { + method: "POST", + path: "/email/verification/resend", + tags: ["Users"], + summary: "Resend verification email", + body: { + description: "", + required: true, + contentType: "application/json", + schema: BodySchema, + }, + responses: { + 200: { description: "OK" }, + ...genericErrors, + }, +} as const satisfies ControllerSchema; diff --git a/packages/api/src/controllers/user/emailVerificationVerify.ts b/packages/api/src/controllers/user/emailVerificationVerify.ts new file mode 100644 index 0000000..41e38fc --- /dev/null +++ b/packages/api/src/controllers/user/emailVerificationVerify.ts @@ -0,0 +1,37 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { z } from "zod/v4"; +import { genericErrors } from "../../http/openapi-helpers.ts"; +import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.ts"; +import { consumeVerificationTokenAndApply } from "../../services/emailVerification.ts"; +import type { Context, ControllerSchema } from "../../types.ts"; +import { parseJsonSafely, sendJson } from "../../utils/http.ts"; + +const BodySchema = z.object({ token: z.string().min(1) }); + +export const postEmailVerificationVerify = withRateLimit("auth")( + async (context: Context, request: IncomingMessage, response: ServerResponse): Promise => { + const body = await getCachedBody(request); + const raw = parseJsonSafely(body); + const parsed = BodySchema.parse(raw); + + await consumeVerificationTokenAndApply(context, parsed.token); + sendJson(response, 200, { success: true, message: "Email verification successful" }); + } +); + +export const schema = { + method: "POST", + path: "/email/verification/verify", + tags: ["Users"], + summary: "Verify email using token", + body: { + description: "", + required: true, + contentType: "application/json", + schema: BodySchema, + }, + responses: { + 200: { description: "OK" }, + ...genericErrors, + }, +} as const satisfies ControllerSchema; diff --git a/packages/api/src/controllers/user/opaqueLoginFinish.test.ts b/packages/api/src/controllers/user/opaqueLoginFinish.test.ts index db438dc..f5151a3 100644 --- a/packages/api/src/controllers/user/opaqueLoginFinish.test.ts +++ b/packages/api/src/controllers/user/opaqueLoginFinish.test.ts @@ -2,15 +2,20 @@ import assert from "node:assert/strict"; import type { IncomingMessage, ServerResponse } from "node:http"; import { beforeEach, describe, mock, test } from "node:test"; import { organizationMembers } from "../../db/schema.ts"; +import { generateEdDSAKeyPair } from "../../services/jwks.ts"; import type { Context } from "../../types.ts"; import { postOpaqueLoginFinish } from "./opaqueLoginFinish.ts"; function createMockResponse() { let payload = ""; + const headers = new Map(); return { statusCode: 0, - setHeader: mock.fn(), + setHeader: mock.fn((key: string, value: string | string[]) => { + headers.set(key.toLowerCase(), value); + }), + getHeader: mock.fn((key: string) => headers.get(key.toLowerCase())), write: mock.fn((chunk: unknown) => { if (chunk !== undefined) { payload += String(chunk); @@ -101,7 +106,8 @@ describe("User OPAQUE Login Finish", () => { let response: ReturnType; let finishLogin = mock.fn(async () => ({ sessionKey: new Uint8Array(32) })); - beforeEach(() => { + beforeEach(async () => { + const { privateJwk, publicJwk, kid } = await generateEdDSAKeyPair(); const userState = { user: { sub: "user-123", @@ -137,6 +143,23 @@ describe("User OPAQUE Login Finish", () => { users: { findFirst: mock.fn(() => Promise.resolve(userState.user)), }, + clients: { + findFirst: mock.fn(() => + Promise.resolve({ + clientId: "user", + accessTokenLifetimeSeconds: 600, + }) + ), + }, + jwks: { + findFirst: mock.fn(() => + Promise.resolve({ + kid, + publicJwk, + privateJwkEnc: Buffer.from(JSON.stringify(privateJwk)), + }) + ), + }, settings: { findFirst: mock.fn(() => Promise.resolve(null)), }, @@ -223,7 +246,12 @@ describe("User OPAQUE Login Finish", () => { throw new Error("KEK service not configured"); } - kekService.decrypt = mock.fn(async (_value: Buffer) => Buffer.from("server@example.com")); + kekService.decrypt = mock.fn(async (value: Buffer) => { + if (value.equals(Buffer.from("encrypted"))) { + return Buffer.from("server@example.com"); + } + return value; + }); context.db.query.opaqueLoginSessions.findFirst = mock.fn(() => Promise.resolve({ id: "session-id", @@ -241,10 +269,12 @@ describe("User OPAQUE Login Finish", () => { ); assert.equal(response.statusCode, 200); - assert.equal(context.services.kek?.decrypt?.mock.calls.length, 1); - const encryptedArg = context.services.kek?.decrypt?.mock.calls[0]?.arguments?.[0] as - | Buffer - | undefined; + assert.ok((context.services.kek?.decrypt?.mock.calls.length || 0) >= 1); + const encryptedCall = context.services.kek?.decrypt?.mock.calls.find((call) => { + const value = call.arguments?.[0] as Buffer | undefined; + return Buffer.isBuffer(value) && value.equals(Buffer.from("encrypted")); + }); + const encryptedArg = encryptedCall?.arguments?.[0] as Buffer | undefined; assert.equal(Buffer.isBuffer(encryptedArg), true); }); @@ -263,4 +293,31 @@ describe("User OPAQUE Login Finish", () => { } ); }); + + test("returns unverified response when verification is required", async () => { + context.db.query.settings.findFirst = mock.fn(() => Promise.resolve({ value: true })); + context.db.query.users.findFirst = mock.fn(() => + Promise.resolve({ + sub: "user-123", + email: "server@example.com", + name: "User", + emailVerifiedAt: null, + }) + ); + + await postOpaqueLoginFinish( + context, + request as IncomingMessage, + response as unknown as ServerResponse + ); + + assert.equal(response.statusCode, 403); + assert.deepEqual(response.json, { + error: "Please verify your email to continue", + code: "EMAIL_UNVERIFIED", + unverified: true, + resendAllowed: true, + email: "server@example.com", + }); + }); }); diff --git a/packages/api/src/controllers/user/opaqueLoginFinish.ts b/packages/api/src/controllers/user/opaqueLoginFinish.ts index e53dd7c..a2d419e 100644 --- a/packages/api/src/controllers/user/opaqueLoginFinish.ts +++ b/packages/api/src/controllers/user/opaqueLoginFinish.ts @@ -106,6 +106,18 @@ export const postOpaqueLoginFinish = withRateLimit("opaque", (body) => { if (!user) { throw new UnauthorizedError("Authentication failed"); } + const requireEmailVerification = + (await getSetting(context, "users.require_email_verification")) === true; + if (requireEmailVerification && !user.emailVerifiedAt) { + sendJson(response, 403, { + error: "Please verify your email to continue", + code: "EMAIL_UNVERIFIED", + unverified: true, + resendAllowed: true, + email: user.email || userEmail, + }); + return; + } const { getUserOrganizations } = await import("../../models/rbac.ts"); const organizations = await getUserOrganizations(context, user.sub); diff --git a/packages/api/src/controllers/user/opaqueRegisterFinish.ts b/packages/api/src/controllers/user/opaqueRegisterFinish.ts index 295cd78..0a395f8 100644 --- a/packages/api/src/controllers/user/opaqueRegisterFinish.ts +++ b/packages/api/src/controllers/user/opaqueRegisterFinish.ts @@ -4,6 +4,7 @@ import { ForbiddenError, ValidationError } from "../../errors.ts"; import { genericErrors } from "../../http/openapi-helpers.ts"; import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.ts"; import { userOpaqueRegisterFinish } from "../../models/registration.ts"; +import { ensureRegistrationAllowedForVerification } from "../../services/emailVerification.ts"; import { requireOpaqueService } from "../../services/opaque.ts"; import { getRefreshTokenTtlSeconds, @@ -46,6 +47,7 @@ export const postOpaqueRegisterFinish = withRateLimit("auth", (body) => if (!enabled) { throw new ForbiddenError("Self-registration disabled"); } + await ensureRegistrationAllowedForVerification(context); // Read and parse request body (may be cached by rate limit middleware) const body = await getCachedBody(request); @@ -81,13 +83,18 @@ export const postOpaqueRegisterFinish = withRateLimit("auth", (body) => email, name, }); - const ttlSeconds = await getSessionTtlSeconds(context, "user"); - const refreshTtlSeconds = await getRefreshTokenTtlSeconds(context, "user"); - issueSessionCookies(response, result.sessionId, ttlSeconds, false); - issueRefreshTokenCookie(response, result.refreshToken, refreshTtlSeconds, false); + if (!result.requiresEmailVerification && result.sessionId && result.refreshToken) { + const ttlSeconds = await getSessionTtlSeconds(context, "user"); + const refreshTtlSeconds = await getRefreshTokenTtlSeconds(context, "user"); + issueSessionCookies(response, result.sessionId, ttlSeconds, false); + issueRefreshTokenCookie(response, result.refreshToken, refreshTtlSeconds, false); + } sendJson(response, 201, { sub: result.sub, - message: "User registered successfully", + message: result.requiresEmailVerification + ? "Please verify your email to continue" + : "User registered successfully", + requiresEmailVerification: result.requiresEmailVerification, }); } catch (error) { sendError(response, error as Error); diff --git a/packages/api/src/controllers/user/opaqueRegisterStart.ts b/packages/api/src/controllers/user/opaqueRegisterStart.ts index d2f8d97..c17333a 100644 --- a/packages/api/src/controllers/user/opaqueRegisterStart.ts +++ b/packages/api/src/controllers/user/opaqueRegisterStart.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { ForbiddenError, ValidationError } from "../../errors.ts"; import { genericErrors } from "../../http/openapi-helpers.ts"; import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.ts"; +import { ensureRegistrationAllowedForVerification } from "../../services/emailVerification.ts"; import { requireOpaqueService } from "../../services/opaque.ts"; import { getSetting } from "../../services/settings.ts"; import type { Context, ControllerSchema } from "../../types.ts"; @@ -29,6 +30,7 @@ export const postOpaqueRegisterStart = withRateLimit("auth", (body) => if (!enabled) { throw new ForbiddenError("Self-registration disabled"); } + await ensureRegistrationAllowedForVerification(context); const body = await getCachedBody(request); const data = parseJsonSafely(body); diff --git a/packages/api/src/controllers/user/profileEmailUpdate.ts b/packages/api/src/controllers/user/profileEmailUpdate.ts new file mode 100644 index 0000000..617d01c --- /dev/null +++ b/packages/api/src/controllers/user/profileEmailUpdate.ts @@ -0,0 +1,54 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { z } from "zod/v4"; +import { ValidationError } from "../../errors.ts"; +import { genericErrors } from "../../http/openapi-helpers.ts"; +import { requestEmailChangeVerification } from "../../services/emailVerification.ts"; +import { requireSession } from "../../services/sessions.ts"; +import type { Context, ControllerSchema } from "../../types.ts"; +import { parseJsonSafely, readBody, sendJson } from "../../utils/http.ts"; + +const BodySchema = z.object({ + email: z.string().email(), +}); + +export async function putUserProfileEmail( + context: Context, + request: IncomingMessage, + response: ServerResponse +): Promise { + const session = await requireSession(context, request, false); + if (!session.sub) { + throw new ValidationError("Invalid user session"); + } + + const body = await readBody(request); + const raw = parseJsonSafely(body); + const parsed = BodySchema.parse(raw); + + await requestEmailChangeVerification(context, { + userSub: session.sub, + email: parsed.email.trim().toLowerCase(), + }); + + sendJson(response, 200, { + success: true, + message: "Please verify your new email to complete the change", + }); +} + +export const schema = { + method: "PUT", + path: "/profile/email", + tags: ["Users"], + summary: "Request email change verification", + body: { + description: "", + required: true, + contentType: "application/json", + schema: BodySchema, + }, + responses: { + 200: { description: "OK" }, + ...genericErrors, + }, +} as const satisfies ControllerSchema; diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index fa07991..c37027f 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -107,10 +107,36 @@ export const users = pgTable("users", { sub: text("sub").primaryKey(), email: text("email").unique(), name: text("name"), + emailVerifiedAt: timestamp("email_verified_at"), + pendingEmail: text("pending_email"), + pendingEmailSetAt: timestamp("pending_email_set_at"), passwordResetRequired: boolean("password_reset_required").default(false).notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), }); +export const emailVerificationTokens = pgTable( + "email_verification_tokens", + { + id: uuid("id").primaryKey().defaultRandom(), + userSub: text("user_sub") + .notNull() + .references(() => users.sub, { onDelete: "cascade" }), + purpose: text("purpose").notNull(), + targetEmail: text("target_email").notNull(), + tokenHash: text("token_hash").notNull().unique(), + expiresAt: timestamp("expires_at").notNull(), + consumedAt: timestamp("consumed_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => ({ + userPurposeIdx: index("email_verification_tokens_user_purpose_idx").on( + table.userSub, + table.purpose + ), + expiresAtIdx: index("email_verification_tokens_expires_at_idx").on(table.expiresAt), + }) +); + export const opaqueRecords = pgTable("opaque_records", { sub: text("sub") .primaryKey() @@ -416,6 +442,14 @@ export const usersRelations = relations(users, ({ one, many }) => ({ sessions: many(sessions), organizations: many(organizationMembers), permissions: many(userPermissions), + emailVerificationTokens: many(emailVerificationTokens), +})); + +export const emailVerificationTokensRelations = relations(emailVerificationTokens, ({ one }) => ({ + user: one(users, { + fields: [emailVerificationTokens.userSub], + references: [users.sub], + }), })); export const clientsRelations = relations(clients, ({ many }) => ({ diff --git a/packages/api/src/http/openapi.ts b/packages/api/src/http/openapi.ts index f468b63..7d634ba 100644 --- a/packages/api/src/http/openapi.ts +++ b/packages/api/src/http/openapi.ts @@ -15,6 +15,11 @@ import { schema as adminClientDeleteSchema } from "../controllers/admin/clientDe import { schema as adminClientSecretSchema } from "../controllers/admin/clientSecret.ts"; import { schema as adminClientsSchema } from "../controllers/admin/clients.ts"; import { schema as adminClientUpdateSchema } from "../controllers/admin/clientUpdate.ts"; +import { + getSchema as adminEmailTemplatesGetSchema, + putSchema as adminEmailTemplatesPutSchema, +} from "../controllers/admin/emailTemplates.ts"; +import { schema as adminEmailTestSchema } from "../controllers/admin/emailTest.ts"; import { schema as adminJwksSchema } from "../controllers/admin/jwks.ts"; import { schema as adminJwksRotateSchema } from "../controllers/admin/jwksRotate.ts"; import { schema as adminLogoutSchema } from "../controllers/admin/logout.ts"; @@ -66,6 +71,8 @@ import { schema as adminUsersSchema } from "../controllers/admin/users.ts"; import { schema as adminUserUpdateSchema } from "../controllers/admin/userUpdate.ts"; import { schema as userAuthorizeSchema } from "../controllers/user/authorize.ts"; import { schema as userAuthorizeFinalizeSchema } from "../controllers/user/authorizeFinalize.ts"; +import { schema as userEmailVerificationResendSchema } from "../controllers/user/emailVerificationResend.ts"; +import { schema as userEmailVerificationVerifySchema } from "../controllers/user/emailVerificationVerify.ts"; import { schema as userEncPublicGetSchema } from "../controllers/user/encPublicGet.ts"; import { schema as userEncPublicPutSchema } from "../controllers/user/encPublicPut.ts"; import { schema as userAppsSchema } from "../controllers/user/getUserApps.ts"; @@ -92,6 +99,7 @@ import { schema as userPasswordChangeFinishSchema } from "../controllers/user/pa import { schema as userPasswordChangeStartSchema } from "../controllers/user/passwordChangeStart.ts"; import { schema as userPasswordChangeVerifyFinishSchema } from "../controllers/user/passwordChangeVerifyFinish.ts"; import { schema as userPasswordChangeVerifyStartSchema } from "../controllers/user/passwordChangeVerifyStart.ts"; +import { schema as userProfileEmailSchema } from "../controllers/user/profileEmailUpdate.ts"; import { schema as userSessionSchema } from "../controllers/user/session.ts"; import { schema as userTokenSchema } from "../controllers/user/token.ts"; import { @@ -156,6 +164,9 @@ const documentedSchemas: ControllerSchema[] = [ adminPermissionDeleteSchema, adminSettingsSchema, adminSettingsUpdateSchema, + adminEmailTestSchema, + adminEmailTemplatesGetSchema, + adminEmailTemplatesPutSchema, adminUserCreateSchema, adminUserUpdateSchema, adminUserDeleteSchema, @@ -186,6 +197,8 @@ const documentedSchemas: ControllerSchema[] = [ userOpaqueLoginFinishSchema, userOpaqueRegisterStartSchema, userOpaqueRegisterFinishSchema, + userEmailVerificationResendSchema, + userEmailVerificationVerifySchema, userOrganizationsSchema, userCreateOrganizationSchema, userOrganizationSchema, @@ -197,6 +210,7 @@ const documentedSchemas: ControllerSchema[] = [ userPasswordChangeFinishSchema, userPasswordChangeVerifyStartSchema, userPasswordChangeVerifyFinishSchema, + userProfileEmailSchema, userEncPublicGetSchema, userEncPublicPutSchema, userWrappedDrkSchema, diff --git a/packages/api/src/http/routers/adminRouter.ts b/packages/api/src/http/routers/adminRouter.ts index fc74be5..8a38d94 100644 --- a/packages/api/src/http/routers/adminRouter.ts +++ b/packages/api/src/http/routers/adminRouter.ts @@ -15,6 +15,11 @@ import { deleteClientController } from "../../controllers/admin/clientDelete.ts" import { getClientSecretController } from "../../controllers/admin/clientSecret.ts"; import { getClients } from "../../controllers/admin/clients.ts"; import { updateClientController } from "../../controllers/admin/clientUpdate.ts"; +import { + getAdminEmailTemplates, + putAdminEmailTemplate, +} from "../../controllers/admin/emailTemplates.ts"; +import { postAdminEmailTest } from "../../controllers/admin/emailTest.ts"; import { getJwks } from "../../controllers/admin/jwks.ts"; import { rotateJwks } from "../../controllers/admin/jwksRotate.ts"; import { postAdminLogout } from "../../controllers/admin/logout.ts"; @@ -410,6 +415,13 @@ export function createAdminRouter(context: Context) { if (method === "GET") return await getSettings(context, request, response); if (method === "PUT") return await updateSettings(context, request, response); } + if (pathname === "/admin/settings/email/test" && method === "POST") { + return await postAdminEmailTest(context, request, response); + } + if (pathname === "/admin/email-templates") { + if (method === "GET") return await getAdminEmailTemplates(context, request, response); + if (method === "PUT") return await putAdminEmailTemplate(context, request, response); + } if (pathname === "/admin/jwks") { if (method === "GET") return await getJwks(context, request, response); diff --git a/packages/api/src/http/routers/userRouter.ts b/packages/api/src/http/routers/userRouter.ts index 5628663..8416109 100644 --- a/packages/api/src/http/routers/userRouter.ts +++ b/packages/api/src/http/routers/userRouter.ts @@ -1,6 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { getAuthorize } from "../../controllers/user/authorize.ts"; import { postAuthorizeFinalize } from "../../controllers/user/authorizeFinalize.ts"; +import { postEmailVerificationResend } from "../../controllers/user/emailVerificationResend.ts"; +import { postEmailVerificationVerify } from "../../controllers/user/emailVerificationVerify.ts"; import { getEncPublicJwk } from "../../controllers/user/encPublicGet.ts"; import { putEncPublicJwk } from "../../controllers/user/encPublicPut.ts"; import { getUserApps } from "../../controllers/user/getUserApps.ts"; @@ -29,6 +31,7 @@ import { postUserPasswordVerifyFinish } from "../../controllers/user/passwordCha import { postUserPasswordVerifyStart } from "../../controllers/user/passwordChangeVerifyStart.ts"; import { postUserPasswordRecoveryVerifyFinish } from "../../controllers/user/passwordRecoveryVerifyFinish.ts"; import { postUserPasswordRecoveryVerifyStart } from "../../controllers/user/passwordRecoveryVerifyStart.ts"; +import { putUserProfileEmail } from "../../controllers/user/profileEmailUpdate.ts"; import { getScopeDescriptions } from "../../controllers/user/scopeDescriptions.ts"; import { getSession } from "../../controllers/user/session.ts"; import { postToken } from "../../controllers/user/token.ts"; @@ -274,6 +277,15 @@ export function createUserRouter(context: Context) { if (method === "POST" && pathname === "/opaque/register/finish") { return await postOpaqueRegisterFinish(context, request, response); } + if (method === "POST" && pathname === "/email/verification/resend") { + return await postEmailVerificationResend(context, request, response); + } + if (method === "POST" && pathname === "/email/verification/verify") { + return await postEmailVerificationVerify(context, request, response); + } + if (method === "PUT" && pathname === "/profile/email") { + return await putUserProfileEmail(context, request, response); + } if (method === "POST" && pathname === "/password/change/start") { return await postUserPasswordChangeStart(context, request, response); diff --git a/packages/api/src/models/emailVerificationTokens.test.ts b/packages/api/src/models/emailVerificationTokens.test.ts new file mode 100644 index 0000000..5885bb3 --- /dev/null +++ b/packages/api/src/models/emailVerificationTokens.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { eq } from "drizzle-orm"; +import { createPglite } from "../db/pglite.ts"; +import { emailVerificationTokens, users } from "../db/schema.ts"; +import { ValidationError } from "../errors.ts"; +import type { Context } from "../types.ts"; +import { + consumeEmailVerificationToken, + createEmailVerificationToken, +} from "./emailVerificationTokens.ts"; + +function createLogger() { + return { + error() {}, + warn() {}, + info() {}, + debug() {}, + trace() {}, + fatal() {}, + }; +} + +test("createEmailVerificationToken invalidates active tokens for same user and purpose", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-email-token-test-")); + const { db, close } = await createPglite(directory); + const context = { db, logger: createLogger() } as Context; + + try { + await db.insert(users).values({ sub: "user-1", email: "user-1@example.com", name: "User One" }); + + const first = await createEmailVerificationToken(context, { + userSub: "user-1", + purpose: "signup_verify", + targetEmail: "user-1@example.com", + ttlMinutes: 30, + }); + const second = await createEmailVerificationToken(context, { + userSub: "user-1", + purpose: "signup_verify", + targetEmail: "user-1@example.com", + ttlMinutes: 30, + }); + + assert.notEqual(first.token, second.token); + + const rows = await db + .select() + .from(emailVerificationTokens) + .where(eq(emailVerificationTokens.userSub, "user-1")); + assert.equal(rows.length, 2); + const activeRows = rows.filter((row) => row.consumedAt === null); + assert.equal(activeRows.length, 1); + } finally { + await close(); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test("consumeEmailVerificationToken consumes once and rejects reuse", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-email-token-test-")); + const { db, close } = await createPglite(directory); + const context = { db, logger: createLogger() } as Context; + + try { + await db.insert(users).values({ sub: "user-1", email: "user-1@example.com", name: "User One" }); + + const created = await createEmailVerificationToken(context, { + userSub: "user-1", + purpose: "email_change_verify", + targetEmail: "new@example.com", + ttlMinutes: 30, + }); + + const consumed = await consumeEmailVerificationToken(context, created.token); + assert.equal(consumed.userSub, "user-1"); + assert.equal(consumed.purpose, "email_change_verify"); + assert.equal(consumed.targetEmail, "new@example.com"); + + await assert.rejects( + () => consumeEmailVerificationToken(context, created.token), + (error: unknown) => + error instanceof ValidationError && + error.message === "Verification link is invalid or expired" + ); + } finally { + await close(); + fs.rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/api/src/models/emailVerificationTokens.ts b/packages/api/src/models/emailVerificationTokens.ts new file mode 100644 index 0000000..a02dbbe --- /dev/null +++ b/packages/api/src/models/emailVerificationTokens.ts @@ -0,0 +1,90 @@ +import { and, eq, gt, isNull } from "drizzle-orm"; +import { emailVerificationTokens } from "../db/schema.ts"; +import { ValidationError } from "../errors.ts"; +import type { Context } from "../types.ts"; +import { generateRandomString, sha256Base64Url } from "../utils/crypto.ts"; + +export type EmailVerificationPurpose = "signup_verify" | "email_change_verify"; + +export interface CreateEmailVerificationTokenParams { + userSub: string; + purpose: EmailVerificationPurpose; + targetEmail: string; + ttlMinutes: number; +} + +export async function invalidateActiveEmailVerificationTokens( + context: Context, + userSub: string, + purpose: EmailVerificationPurpose +): Promise { + await context.db + .update(emailVerificationTokens) + .set({ consumedAt: new Date() }) + .where( + and( + eq(emailVerificationTokens.userSub, userSub), + eq(emailVerificationTokens.purpose, purpose), + isNull(emailVerificationTokens.consumedAt), + gt(emailVerificationTokens.expiresAt, new Date()) + ) + ); +} + +export async function createEmailVerificationToken( + context: Context, + params: CreateEmailVerificationTokenParams +): Promise<{ token: string; expiresAt: Date }> { + const ttlMinutes = Math.max(1, params.ttlMinutes); + await invalidateActiveEmailVerificationTokens(context, params.userSub, params.purpose); + + const token = generateRandomString(48); + const tokenHash = sha256Base64Url(token); + const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000); + + await context.db.insert(emailVerificationTokens).values({ + userSub: params.userSub, + purpose: params.purpose, + targetEmail: params.targetEmail, + tokenHash, + expiresAt, + createdAt: new Date(), + }); + + return { token, expiresAt }; +} + +export async function consumeEmailVerificationToken( + context: Context, + token: string +): Promise<{ + id: string; + userSub: string; + purpose: EmailVerificationPurpose; + targetEmail: string; +}> { + const tokenHash = sha256Base64Url(token); + const row = await context.db.query.emailVerificationTokens.findFirst({ + where: eq(emailVerificationTokens.tokenHash, tokenHash), + }); + + if (!row || row.consumedAt || row.expiresAt <= new Date()) { + throw new ValidationError("Verification link is invalid or expired"); + } + + await context.db + .update(emailVerificationTokens) + .set({ consumedAt: new Date() }) + .where(eq(emailVerificationTokens.id, row.id)); + + if (row.purpose !== "signup_verify" && row.purpose !== "email_change_verify") { + throw new ValidationError("Verification link is invalid or expired"); + } + + return { + id: row.id, + userSub: row.userSub, + purpose: row.purpose, + targetEmail: row.targetEmail, + }; +} diff --git a/packages/api/src/models/registration.ts b/packages/api/src/models/registration.ts index 0fee44a..7b5d58b 100644 --- a/packages/api/src/models/registration.ts +++ b/packages/api/src/models/registration.ts @@ -8,6 +8,7 @@ import { users, } from "../db/schema.ts"; import { ValidationError } from "../errors.ts"; +import { sendSignupVerification } from "../services/emailVerification.ts"; import { createSession } from "../services/sessions.ts"; import { getSetting } from "../services/settings.ts"; import type { Context } from "../types.ts"; @@ -20,6 +21,8 @@ export async function userOpaqueRegisterFinish( const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(data.email)) throw new ValidationError("Invalid email format"); const opaqueRecord = await context.services.opaque.finishRegistration(data.record, data.email); + const requireEmailVerification = + (await getSetting(context, "users.require_email_verification")) === true; const { generateRandomString } = await import("../utils/crypto.ts"); const sub = generateRandomString(16); // Check if user already exists before transaction @@ -28,20 +31,21 @@ export async function userOpaqueRegisterFinish( where: eq(users.email, data.email), }); if (existingUser) { - // Return a fake success response without modifying existing user data - // This prevents attackers from discovering which emails are registered const { generateRandomString: genFakeId } = await import("../utils/crypto.ts"); return { sub: genFakeId(16), - sessionId: genFakeId(32), - refreshToken: genFakeId(64), + requiresEmailVerification: true, }; } await context.db.transaction(async (tx) => { - await tx - .insert(users) - .values({ sub, email: data.email, name: data.name, createdAt: new Date() }); + await tx.insert(users).values({ + sub, + email: data.email, + name: data.name, + emailVerifiedAt: requireEmailVerification ? null : new Date(), + createdAt: new Date(), + }); const defaultOrg = await tx.query.organizations.findFirst({ where: eq(organizations.slug, "default"), }); @@ -71,6 +75,15 @@ export async function userOpaqueRegisterFinish( updatedAt: new Date(), }); }); + if (requireEmailVerification) { + await sendSignupVerification(context, { + userSub: sub, + email: data.email, + name: data.name, + }); + return { sub, requiresEmailVerification: true }; + } + const uiUserSettings = (await getSetting(context, "ui_user")) as | { clientId?: string } | undefined @@ -85,5 +98,10 @@ export async function userOpaqueRegisterFinish( name: data.name, clientId: userClientId, }); - return { sub, sessionId: sessionInfo.sessionId, refreshToken: sessionInfo.refreshToken }; + return { + sub, + sessionId: sessionInfo.sessionId, + refreshToken: sessionInfo.refreshToken, + requiresEmailVerification: false, + }; } diff --git a/packages/api/src/services/email.test.ts b/packages/api/src/services/email.test.ts new file mode 100644 index 0000000..db525a4 --- /dev/null +++ b/packages/api/src/services/email.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { AppError, ValidationError } from "../errors.ts"; +import type { Context } from "../types.ts"; +import { getSmtpMissingFields, isEmailSendingAvailable, sendEmail } from "./email.ts"; + +function createContext(values: unknown[]): Context { + let index = 0; + return { + db: { + query: { + settings: { + findFirst: async () => ({ value: values[index++] }), + }, + }, + }, + services: {}, + logger: { + error() {}, + warn() {}, + info() {}, + debug() {}, + trace() {}, + fatal() {}, + }, + } as unknown as Context; +} + +test("getSmtpMissingFields returns missing required keys", () => { + const missing = getSmtpMissingFields({ + enabled: true, + transport: "smtp", + from: "", + host: "", + port: 0, + user: "", + password: "", + }); + + assert.deepEqual(missing, [ + "email.from", + "email.smtp.host", + "email.smtp.port", + "email.smtp.user", + "email.smtp.password", + ]); +}); + +test("isEmailSendingAvailable returns true only for complete smtp configuration", async () => { + const completeContext = createContext([ + true, + "smtp", + "noreply@example.com", + "smtp.example.com", + 587, + "smtp-user", + "smtp-pass", + ]); + const disabledContext = createContext([ + false, + "smtp", + "noreply@example.com", + "smtp.example.com", + 587, + "smtp-user", + "smtp-pass", + ]); + const badTransportContext = createContext([ + true, + "ses", + "noreply@example.com", + "smtp.example.com", + 587, + "smtp-user", + "smtp-pass", + ]); + + assert.equal(await isEmailSendingAvailable(completeContext), true); + assert.equal(await isEmailSendingAvailable(disabledContext), false); + assert.equal(await isEmailSendingAvailable(badTransportContext), false); +}); + +test("sendEmail rejects disabled transport before attempting delivery", async () => { + const context = createContext([ + false, + "smtp", + "noreply@example.com", + "smtp.example.com", + 587, + "smtp-user", + "smtp-pass", + ]); + + await assert.rejects( + () => + sendEmail(context, { + to: "user@example.com", + subject: "Subject", + text: "Text", + html: "

Text

", + }), + (error: unknown) => + error instanceof AppError && + error.code === "EMAIL_TRANSPORT_DISABLED" && + error.message === "Email transport is disabled" + ); +}); + +test("sendEmail rejects incomplete smtp settings", async () => { + const context = createContext([ + true, + "smtp", + "noreply@example.com", + "smtp.example.com", + 587, + "", + "smtp-pass", + ]); + + await assert.rejects( + () => + sendEmail(context, { + to: "user@example.com", + subject: "Subject", + text: "Text", + html: "

Text

", + }), + (error: unknown) => + error instanceof ValidationError && error.message === "SMTP settings are incomplete" + ); +}); diff --git a/packages/api/src/services/email.ts b/packages/api/src/services/email.ts new file mode 100644 index 0000000..2c5b2d1 --- /dev/null +++ b/packages/api/src/services/email.ts @@ -0,0 +1,134 @@ +import { AppError, ValidationError } from "../errors.ts"; +import type { Context } from "../types.ts"; +import { type EmailTemplateKey, renderEmailTemplate } from "./emailTemplates.ts"; +import { getSetting } from "./settings.ts"; + +interface SmtpSettings { + enabled: boolean; + transport: string; + from: string; + host: string; + port: number; + user: string; + password: string; +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function asNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function asBoolean(value: unknown): boolean { + return value === true; +} + +export async function getSmtpSettings(context: Context): Promise { + const [enabled, transport, from, host, port, user, password] = await Promise.all([ + getSetting(context, "email.smtp.enabled"), + getSetting(context, "email.transport"), + getSetting(context, "email.from"), + getSetting(context, "email.smtp.host"), + getSetting(context, "email.smtp.port"), + getSetting(context, "email.smtp.user"), + getSetting(context, "email.smtp.password"), + ]); + + return { + enabled: asBoolean(enabled), + transport: asString(transport) || "smtp", + from: asString(from), + host: asString(host), + port: asNumber(port), + user: asString(user), + password: asString(password), + }; +} + +export function getSmtpMissingFields(settings: SmtpSettings): string[] { + const missing: string[] = []; + if (!settings.from) missing.push("email.from"); + if (!settings.transport) missing.push("email.transport"); + if (!settings.host) missing.push("email.smtp.host"); + if (!settings.port || settings.port < 1 || settings.port > 65535) missing.push("email.smtp.port"); + if (!settings.user) missing.push("email.smtp.user"); + if (!settings.password) missing.push("email.smtp.password"); + return missing; +} + +export async function isEmailSendingAvailable(context: Context): Promise { + const settings = await getSmtpSettings(context); + if (!settings.enabled) return false; + if (settings.transport !== "smtp") return false; + return getSmtpMissingFields(settings).length === 0; +} + +export async function sendEmail( + context: Context, + params: { to: string; subject: string; text: string; html: string } +): Promise { + const settings = await getSmtpSettings(context); + if (!settings.enabled) { + throw new AppError("Email transport is disabled", "EMAIL_TRANSPORT_DISABLED", 400); + } + if (settings.transport !== "smtp") { + throw new ValidationError("Unsupported email transport"); + } + + const missing = getSmtpMissingFields(settings); + if (missing.length > 0) { + throw new ValidationError("SMTP settings are incomplete", { missing }); + } + + const nodemailer = await import("nodemailer"); + const transporter = nodemailer.createTransport({ + host: settings.host, + port: settings.port, + secure: settings.port === 465, + auth: { + user: settings.user, + pass: settings.password, + }, + }); + + await transporter.sendMail({ + from: settings.from, + to: params.to, + subject: params.subject, + text: params.text, + html: params.html, + }); +} + +export async function sendTemplatedEmail( + context: Context, + params: { + to: string; + template: EmailTemplateKey; + variables: Record; + } +): Promise { + const rendered = await renderEmailTemplate(context, params.template, params.variables); + await sendEmail(context, { + to: params.to, + subject: rendered.subject, + text: rendered.text, + html: rendered.html, + }); +} + +export async function sendTestEmailToCurrentAdmin( + context: Context, + adminEmail: string +): Promise { + const sentAt = new Date().toISOString(); + await sendTemplatedEmail(context, { + to: adminEmail, + template: "admin_test_email", + variables: { + sent_at: sentAt, + }, + }); +} diff --git a/packages/api/src/services/emailTemplates.ts b/packages/api/src/services/emailTemplates.ts new file mode 100644 index 0000000..97e13b9 --- /dev/null +++ b/packages/api/src/services/emailTemplates.ts @@ -0,0 +1,123 @@ +import type { Context } from "../types.ts"; +import { getSetting, setSetting } from "./settings.ts"; + +export type EmailTemplateKey = + | "signup_verification" + | "verification_resend_confirmation" + | "email_change_verification" + | "password_recovery" + | "admin_test_email"; + +export interface EmailTemplate { + subject: string; + text: string; + html: string; +} + +const DEFAULT_TEMPLATES: Record = { + signup_verification: { + subject: "Verify your email", + text: "Hello {{name}},\n\nPlease verify your email by opening this link:\n{{verification_link}}\n\nIf you did not create this account, ignore this email.", + html: '

Hello {{name}},

Please verify your email by opening this link:

Verify email

If you did not create this account, ignore this email.

', + }, + verification_resend_confirmation: { + subject: "A new verification link has been sent", + text: "Hello {{name}},\n\nA new verification link has been requested for this account.\n\nIf this was you, use the newest email in your inbox.", + html: "

Hello {{name}},

A new verification link has been requested for this account.

If this was you, use the newest email in your inbox.

", + }, + email_change_verification: { + subject: "Verify your new email address", + text: "Hello {{name}},\n\nPlease verify your new email address by opening this link:\n{{verification_link}}\n\nYour current email remains active until verification completes.", + html: '

Hello {{name}},

Please verify your new email address by opening this link:

Verify new email

Your current email remains active until verification completes.

', + }, + password_recovery: { + subject: "Password recovery", + text: "Hello {{name}},\n\nUse this link to recover access to your account:\n{{recovery_link}}", + html: '

Hello {{name}},

Use this link to recover access to your account:

Recover account

', + }, + admin_test_email: { + subject: "DarkAuth SMTP test", + text: "This is a test email from DarkAuth.\n\nSent at: {{sent_at}}", + html: "

This is a test email from DarkAuth.

Sent at: {{sent_at}}

", + }, +}; + +function templateBaseKey(key: EmailTemplateKey): string { + return `email.templates.${key}`; +} + +function renderValue(template: string, variables: Record): string { + return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_full, varName: string) => { + return variables[varName] ?? ""; + }); +} + +export function listTemplateDefaults(): Record { + return DEFAULT_TEMPLATES; +} + +export async function ensureEmailTemplateDefaults(context: Context): Promise { + const keys = Object.keys(DEFAULT_TEMPLATES) as EmailTemplateKey[]; + for (const key of keys) { + const existing = (await getSetting(context, templateBaseKey(key))) as EmailTemplate | undefined; + if (!existing || typeof existing !== "object") { + await setSetting(context, templateBaseKey(key), DEFAULT_TEMPLATES[key]); + } + } +} + +export async function getEmailTemplate( + context: Context, + key: EmailTemplateKey +): Promise { + const stored = (await getSetting(context, templateBaseKey(key))) as + | Partial + | undefined + | null; + const defaults = DEFAULT_TEMPLATES[key]; + if (!stored || typeof stored !== "object") { + return defaults; + } + + return { + subject: typeof stored.subject === "string" ? stored.subject : defaults.subject, + text: typeof stored.text === "string" ? stored.text : defaults.text, + html: typeof stored.html === "string" ? stored.html : defaults.html, + }; +} + +export async function getAllEmailTemplates( + context: Context +): Promise> { + await ensureEmailTemplateDefaults(context); + const keys = Object.keys(DEFAULT_TEMPLATES) as EmailTemplateKey[]; + const entries = await Promise.all( + keys.map(async (key) => [key, await getEmailTemplate(context, key)] as const) + ); + return Object.fromEntries(entries) as Record; +} + +export async function updateEmailTemplate( + context: Context, + key: EmailTemplateKey, + template: EmailTemplate +): Promise { + await setSetting(context, templateBaseKey(key), { + subject: template.subject, + text: template.text, + html: template.html, + }); +} + +export async function renderEmailTemplate( + context: Context, + key: EmailTemplateKey, + variables: Record +): Promise { + const template = await getEmailTemplate(context, key); + return { + subject: renderValue(template.subject, variables), + text: renderValue(template.text, variables), + html: renderValue(template.html, variables), + }; +} diff --git a/packages/api/src/services/emailVerification.test.ts b/packages/api/src/services/emailVerification.test.ts new file mode 100644 index 0000000..021a77a --- /dev/null +++ b/packages/api/src/services/emailVerification.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { AppError } from "../errors.ts"; +import type { Context } from "../types.ts"; +import { + ensureRegistrationAllowedForVerification, + getVerificationTokenTtlMinutes, +} from "./emailVerification.ts"; + +function createContext(values: unknown[]): Context { + let index = 0; + return { + db: { + query: { + settings: { + findFirst: async () => ({ value: values[index++] }), + }, + }, + }, + services: {}, + config: { + publicOrigin: "https://auth.example.com", + }, + logger: { + error() {}, + warn() {}, + info() {}, + debug() {}, + trace() {}, + fatal() {}, + }, + } as unknown as Context; +} + +test("getVerificationTokenTtlMinutes clamps out-of-range settings", async () => { + const low = createContext([1]); + const high = createContext([20000]); + const valid = createContext([120]); + + assert.equal(await getVerificationTokenTtlMinutes(low), 5); + assert.equal(await getVerificationTokenTtlMinutes(high), 10080); + assert.equal(await getVerificationTokenTtlMinutes(valid), 120); +}); + +test("ensureRegistrationAllowedForVerification blocks when verification is required without email transport", async () => { + const blocked = createContext([true, false, "smtp", "", "", 0, "", ""]); + const allowed = createContext([false]); + + await assert.rejects( + () => ensureRegistrationAllowedForVerification(blocked), + (error: unknown) => + error instanceof AppError && + error.code === "REGISTRATION_DISABLED" && + error.message === "Registration currently disabled" + ); + await assert.doesNotReject(() => ensureRegistrationAllowedForVerification(allowed)); +}); diff --git a/packages/api/src/services/emailVerification.ts b/packages/api/src/services/emailVerification.ts new file mode 100644 index 0000000..99adcf5 --- /dev/null +++ b/packages/api/src/services/emailVerification.ts @@ -0,0 +1,221 @@ +import { and, eq, ne } from "drizzle-orm"; +import { users } from "../db/schema.ts"; +import { AppError, ValidationError } from "../errors.ts"; +import { + consumeEmailVerificationToken, + createEmailVerificationToken, + type EmailVerificationPurpose, +} from "../models/emailVerificationTokens.ts"; +import type { Context } from "../types.ts"; +import { logAuditEvent } from "./audit.ts"; +import { isEmailSendingAvailable, sendTemplatedEmail } from "./email.ts"; +import { getSetting } from "./settings.ts"; + +function getVerificationLink(context: Context, token: string): string { + const base = context.config.publicOrigin; + return `${base}/verify-email?token=${encodeURIComponent(token)}`; +} + +export async function getVerificationTokenTtlMinutes(context: Context): Promise { + const raw = (await getSetting(context, "email.verification.token_ttl_minutes")) as + | number + | undefined + | null; + const ttl = typeof raw === "number" ? raw : 1440; + if (ttl < 5) return 5; + if (ttl > 10080) return 10080; + return ttl; +} + +async function issueVerificationEmail( + context: Context, + params: { + userSub: string; + name: string; + targetEmail: string; + purpose: EmailVerificationPurpose; + } +): Promise { + const ttlMinutes = await getVerificationTokenTtlMinutes(context); + const { token } = await createEmailVerificationToken(context, { + userSub: params.userSub, + purpose: params.purpose, + targetEmail: params.targetEmail, + ttlMinutes, + }); + const verificationLink = getVerificationLink(context, token); + const template = + params.purpose === "signup_verify" ? "signup_verification" : "email_change_verification"; + await sendTemplatedEmail(context, { + to: params.targetEmail, + template, + variables: { + name: params.name || params.targetEmail, + verification_link: verificationLink, + }, + }); +} + +export async function ensureRegistrationAllowedForVerification(context: Context): Promise { + const requireVerification = + (await getSetting(context, "users.require_email_verification")) === true; + if (!requireVerification) return; + const available = await isEmailSendingAvailable(context); + if (!available) { + throw new AppError("Registration currently disabled", "REGISTRATION_DISABLED", 403); + } +} + +export async function sendSignupVerification( + context: Context, + params: { userSub: string; email: string; name: string } +): Promise { + await issueVerificationEmail(context, { + userSub: params.userSub, + name: params.name, + targetEmail: params.email, + purpose: "signup_verify", + }); + await logAuditEvent(context, { + eventType: "USER_EMAIL_VERIFICATION_SENT", + cohort: "user", + userId: params.userSub, + ipAddress: "system", + success: true, + resourceType: "user", + resourceId: params.userSub, + }); +} + +export async function resendSignupVerificationByEmail( + context: Context, + email: string +): Promise { + const user = await context.db.query.users.findFirst({ where: eq(users.email, email) }); + if (!user || user.emailVerifiedAt) { + return; + } + + const available = await isEmailSendingAvailable(context); + if (!available) { + return; + } + + await issueVerificationEmail(context, { + userSub: user.sub, + name: user.name || "", + targetEmail: user.email || email, + purpose: "signup_verify", + }); + + await logAuditEvent(context, { + eventType: "USER_EMAIL_VERIFICATION_RESENT", + cohort: "user", + userId: user.sub, + ipAddress: "system", + success: true, + resourceType: "user", + resourceId: user.sub, + }); +} + +export async function consumeVerificationTokenAndApply( + context: Context, + token: string +): Promise { + const consumed = await consumeEmailVerificationToken(context, token); + + if (consumed.purpose === "signup_verify") { + await context.db + .update(users) + .set({ emailVerifiedAt: new Date() }) + .where(eq(users.sub, consumed.userSub)); + + await logAuditEvent(context, { + eventType: "USER_EMAIL_VERIFIED", + cohort: "user", + userId: consumed.userSub, + ipAddress: "system", + success: true, + resourceType: "user", + resourceId: consumed.userSub, + }); + + return; + } + + await context.db.transaction(async (tx) => { + const conflict = await tx.query.users.findFirst({ + where: and(eq(users.email, consumed.targetEmail), ne(users.sub, consumed.userSub)), + }); + if (conflict) { + throw new ValidationError("Email is already in use"); + } + + await tx + .update(users) + .set({ + email: consumed.targetEmail, + pendingEmail: null, + pendingEmailSetAt: null, + emailVerifiedAt: new Date(), + }) + .where(eq(users.sub, consumed.userSub)); + }); + + await logAuditEvent(context, { + eventType: "USER_EMAIL_CHANGE_VERIFIED", + cohort: "user", + userId: consumed.userSub, + ipAddress: "system", + success: true, + resourceType: "user", + resourceId: consumed.userSub, + }); +} + +export async function requestEmailChangeVerification( + context: Context, + params: { userSub: string; email: string } +): Promise { + const user = await context.db.query.users.findFirst({ where: eq(users.sub, params.userSub) }); + if (!user) { + throw new ValidationError("User not found"); + } + if (!params.email || params.email === user.email) { + throw new ValidationError("Invalid email"); + } + const conflict = await context.db.query.users.findFirst({ + where: and(eq(users.email, params.email), ne(users.sub, params.userSub)), + }); + if (conflict) { + throw new ValidationError("Email is already in use"); + } + + const available = await isEmailSendingAvailable(context); + if (!available) { + throw new ValidationError("Email transport is not available"); + } + + await context.db + .update(users) + .set({ pendingEmail: params.email, pendingEmailSetAt: new Date() }) + .where(eq(users.sub, params.userSub)); + + await issueVerificationEmail(context, { + userSub: user.sub, + name: user.name || "", + targetEmail: params.email, + purpose: "email_change_verify", + }); + + await logAuditEvent(context, { + eventType: "USER_EMAIL_CHANGE_REQUESTED", + cohort: "user", + userId: params.userSub, + ipAddress: "system", + success: true, + resourceType: "user", + resourceId: params.userSub, + }); +} diff --git a/packages/api/src/services/settings.ts b/packages/api/src/services/settings.ts index bbcc226..340f470 100644 --- a/packages/api/src/services/settings.ts +++ b/packages/api/src/services/settings.ts @@ -141,6 +141,7 @@ export async function seedDefaultSettings( tags?: string[]; defaultValue: unknown; value: unknown; + secure?: boolean; }> = [ { key: "issuer", @@ -163,6 +164,16 @@ export async function seedDefaultSettings( defaultValue: false, value: false, }, + { + key: "users.require_email_verification", + name: "Require Email Verification", + type: "boolean", + category: "Users", + description: "Require users to verify their email before login completes", + tags: ["users", "email"], + defaultValue: false, + value: false, + }, { key: "public_origin", name: "Public Origin", @@ -640,6 +651,87 @@ export async function seedDefaultSettings( require_for_users: false, }, }, + { + key: "email.transport", + name: "Email Transport", + type: "string", + category: "Email / SMTP", + description: "Email transport provider", + tags: ["email", "smtp"], + defaultValue: "smtp", + value: "smtp", + }, + { + key: "email.from", + name: "From Address", + type: "string", + category: "Email / SMTP", + description: "From address for outgoing emails", + tags: ["email", "smtp"], + defaultValue: "", + value: "", + }, + { + key: "email.smtp.host", + name: "SMTP Host", + type: "string", + category: "Email / SMTP", + description: "SMTP host", + tags: ["email", "smtp"], + defaultValue: "", + value: "", + }, + { + key: "email.smtp.port", + name: "SMTP Port", + type: "number", + category: "Email / SMTP", + description: "SMTP port", + tags: ["email", "smtp"], + defaultValue: 587, + value: 587, + }, + { + key: "email.smtp.user", + name: "SMTP User", + type: "string", + category: "Email / SMTP", + description: "SMTP username", + tags: ["email", "smtp"], + defaultValue: "", + value: "", + }, + { + key: "email.smtp.password", + name: "SMTP Password", + type: "string", + category: "Email / SMTP", + description: "SMTP password", + tags: ["email", "smtp"], + defaultValue: "", + value: "", + secure: true, + }, + { + key: "email.smtp.enabled", + name: "SMTP Enabled", + type: "boolean", + category: "Email / SMTP", + description: "Enable SMTP for outgoing email", + tags: ["email", "smtp"], + defaultValue: false, + value: false, + }, + { + key: "email.verification.token_ttl_minutes", + name: "Verification Token TTL (minutes)", + type: "number", + category: "Email / Verification", + description: "Minutes until email verification links expire", + tags: ["email", "verification"], + defaultValue: 1440, + value: 1440, + }, ]; const brandingDefaults = { @@ -828,6 +920,20 @@ export async function seedDefaultSettings( ]; items.push(...brandingItems); + const { listTemplateDefaults } = await import("./emailTemplates.ts"); + const templateDefaults = listTemplateDefaults(); + for (const [templateKey, template] of Object.entries(templateDefaults)) { + items.push({ + key: `email.templates.${templateKey}`, + name: `${templateKey} Template`, + type: "object", + category: "Email / Templates", + description: `Template for ${templateKey}`, + tags: ["email", "templates"], + defaultValue: template, + value: template, + }); + } for (const s of items) { await context.db @@ -841,7 +947,7 @@ export async function seedDefaultSettings( tags: s.tags || [], defaultValue: s.defaultValue, value: s.value, - secure: false, + secure: s.secure === true, updatedAt: new Date(), }) .onConflictDoUpdate({ @@ -854,6 +960,7 @@ export async function seedDefaultSettings( tags: s.tags || [], defaultValue: s.defaultValue, value: s.value, + secure: s.secure === true, updatedAt: new Date(), }, }); From 01e2ce60bde3be1cb4a9ec9f57d04c2342498228 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sun, 1 Mar 2026 21:07:19 +0000 Subject: [PATCH 2/5] feat(admin-ui,user-ui): add email templates and verification flows --- packages/admin-ui/src/App.tsx | 9 + .../admin-ui/src/components/app-sidebar.tsx | 1 + .../src/components/ui/tabs.module.css | 55 ++- .../admin-ui/src/pages/ClientEdit.module.css | 63 +-- .../src/pages/EmailTemplates.module.css | 100 +++++ .../admin-ui/src/pages/EmailTemplates.tsx | 250 +++++++++++ .../admin-ui/src/pages/Install.module.css | 217 ++++++++-- packages/admin-ui/src/pages/Install.tsx | 401 ++++++++++++------ packages/admin-ui/src/pages/Roles.tsx | 6 +- packages/admin-ui/src/pages/Settings.tsx | 65 ++- packages/admin-ui/src/services/api.ts | 28 ++ packages/user-ui/src/App.tsx | 2 + .../user-ui/src/components/ChangePassword.tsx | 17 +- packages/user-ui/src/components/Login.tsx | 75 +++- .../src/components/LoginView.module.css | 82 ++++ packages/user-ui/src/components/OtpFlow.tsx | 39 +- .../user-ui/src/components/OtpVerifyView.tsx | 16 +- .../src/components/Register.module.css | 4 + packages/user-ui/src/components/Register.tsx | 270 ++++++------ .../user-ui/src/components/ResetPassword.tsx | 5 +- .../src/components/SettingsSecurity.tsx | 89 ++-- .../src/components/VerifyEmailView.tsx | 92 ++++ packages/user-ui/src/services/api.ts | 41 +- 23 files changed, 1460 insertions(+), 467 deletions(-) create mode 100644 packages/admin-ui/src/pages/EmailTemplates.module.css create mode 100644 packages/admin-ui/src/pages/EmailTemplates.tsx create mode 100644 packages/user-ui/src/components/VerifyEmailView.tsx diff --git a/packages/admin-ui/src/App.tsx b/packages/admin-ui/src/App.tsx index caf0ff9..d53ee87 100644 --- a/packages/admin-ui/src/App.tsx +++ b/packages/admin-ui/src/App.tsx @@ -20,6 +20,7 @@ import Changelog from "./pages/Changelog"; import ClientEdit from "./pages/ClientEdit"; import Clients from "./pages/Clients"; import Dashboard from "./pages/Dashboard"; +import EmailTemplates from "./pages/EmailTemplates"; import ErrorPage from "./pages/Error"; import Install from "./pages/Install"; import Keys from "./pages/Keys"; @@ -445,6 +446,14 @@ const App = () => { } /> + + + + } + /> definition.key === value); +} + +export default function EmailTemplates() { + const { toast } = useToast(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [templates, setTemplates] = useState | null>(null); + const [selectedKey, setSelectedKey] = useState("signup_verification"); + const [subject, setSubject] = useState(""); + const [text, setText] = useState(""); + const [html, setHtml] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + try { + const response = await adminApiService.getEmailTemplates(); + setTemplates(response.templates); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Failed to load templates", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + load(); + }, [load]); + + const activeTemplate = useMemo(() => { + if (!templates) return null; + return templates[selectedKey] || null; + }, [templates, selectedKey]); + + const activeDefinition = useMemo( + () => TEMPLATE_DEFINITIONS.find((definition) => definition.key === selectedKey) ?? null, + [selectedKey] + ); + + useEffect(() => { + if (!activeTemplate) return; + setSubject(activeTemplate.subject); + setText(activeTemplate.text); + setHtml(activeTemplate.html); + }, [activeTemplate]); + + useEffect(() => { + const tabFromUrl = new URLSearchParams(window.location.search).get("tab"); + if (!tabFromUrl || !isEmailTemplateKey(tabFromUrl)) return; + setSelectedKey(tabFromUrl); + }, []); + + const setTab = (value: string) => { + if (!isEmailTemplateKey(value)) return; + setSelectedKey(value); + const url = new URL(window.location.href); + url.searchParams.set("tab", value); + window.history.replaceState(window.history.state, "", url.toString()); + }; + + const hasChanges = + !!activeTemplate && + (subject !== activeTemplate.subject || + text !== activeTemplate.text || + html !== activeTemplate.html); + + const resetDraft = () => { + if (!activeTemplate) return; + setSubject(activeTemplate.subject); + setText(activeTemplate.text); + setHtml(activeTemplate.html); + }; + + const handleSave = async () => { + if (!hasChanges) return; + setSaving(true); + try { + await adminApiService.updateEmailTemplate(selectedKey, { subject, text, html }); + toast({ title: "Saved", description: `${activeDefinition?.label || "Template"} updated` }); + await load(); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Failed to save template", + variant: "destructive", + }); + } finally { + setSaving(false); + } + }; + + return ( +
+ + + +
+ } + /> + +
+ {loading || !templates ? ( + + Loading templates... + + ) : ( + + + {TEMPLATE_DEFINITIONS.map((definition) => ( + + {definition.label} + + ))} + + + {TEMPLATE_DEFINITIONS.map((definition) => ( + + + + {definition.label} + {definition.description} + + +
+ + setSubject(e.target.value)} + placeholder="Email subject line" + /> +
+ +
+
+ +