diff --git a/e2e/site-invitations.spec.ts b/e2e/site-invitations.spec.ts index 6c9e5778..931c5791 100644 --- a/e2e/site-invitations.spec.ts +++ b/e2e/site-invitations.spec.ts @@ -9,6 +9,7 @@ async function saveEmailConfig(page: import('@playwright/test').Page) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, + requireEmailVerification: false, provider: 'http', from: 'no-reply@example.com', http: { diff --git a/server/auth.integration.test.ts b/server/auth.integration.test.ts index ba526fa3..3ccff5d8 100644 --- a/server/auth.integration.test.ts +++ b/server/auth.integration.test.ts @@ -20,6 +20,17 @@ async function signUp(ctx: TestCtx, email: string, extra?: Record { }) }) +describe('dynamic email verification policy', () => { + it('requires verification immediately and resends the email on sign-in', async () => { + const { vi } = await import('vitest') + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + + try { + const ctx = await createTestApp() + await configureRequiredEmailVerification(ctx) + + const signUpResponse = await signUp(ctx, 'required@example.com', { username: 'required_user' }) + expect(signUpResponse.status).toBe(200) + await expect(signUpResponse.json()).resolves.toMatchObject({ token: null }) + expect(await ctx.db.select().from(authSchema.session)).toHaveLength(0) + expect(fetchMock).toHaveBeenCalledTimes(1) + + const signInResponse = await ctx.app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'required@example.com', password: 'password123456' }), + }) + expect(signInResponse.status).toBe(403) + await expect(signInResponse.json()).resolves.toMatchObject({ code: 'EMAIL_NOT_VERIFIED' }) + expect(fetchMock).toHaveBeenCalledTimes(2) + + const usernameSignInResponse = await ctx.app.request('/api/auth/sign-in/username', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'required_user', password: 'password123456' }), + }) + expect(usernameSignInResponse.status).toBe(403) + await expect(usernameSignInResponse.json()).resolves.toMatchObject({ code: 'EMAIL_NOT_VERIFIED' }) + expect(fetchMock).toHaveBeenCalledTimes(3) + } finally { + vi.unstubAllGlobals() + } + }) + + it('accepts the verification link and marks the user as verified', async () => { + const { vi } = await import('vitest') + const fetchMock = vi.fn().mockResolvedValue({ ok: true }) + vi.stubGlobal('fetch', fetchMock) + + try { + const ctx = await createTestApp() + await configureRequiredEmailVerification(ctx) + await signUp(ctx, 'verify-required@example.com') + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined + const payload = JSON.parse(String(request?.body)) as { html: string } + const verificationUrl = payload.html.match(/href="([^"]+)"/)?.[1] + if (!verificationUrl) throw new Error('Verification email did not contain a link') + + const url = new URL(verificationUrl) + const verifyResponse = await ctx.app.request(`${url.pathname}${url.search}`) + expect(verifyResponse.status).toBe(302) + + const [user] = await ctx.db + .select({ emailVerified: authSchema.user.emailVerified }) + .from(authSchema.user) + .where(eq(authSchema.user.email, 'verify-required@example.com')) + .limit(1) + expect(user.emailVerified).toBe(true) + } finally { + vi.unstubAllGlobals() + } + }) + + it('applies a disabled policy without recreating the auth service', async () => { + const { vi } = await import('vitest') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + + try { + const ctx = await createTestApp() + await configureRequiredEmailVerification(ctx) + await signUp(ctx, 'toggle@example.com') + await ctx.db + .update(schema.systemOptions) + .set({ value: 'false' }) + .where(eq(schema.systemOptions.key, 'auth_require_email_verification')) + + const signInResponse = await ctx.app.request('/api/auth/sign-in/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'toggle@example.com', password: 'password123456' }), + }) + expect(signInResponse.status).toBe(200) + expect(signInResponse.headers.getSetCookie()).not.toHaveLength(0) + } finally { + vi.unstubAllGlobals() + } + }) +}) + describe('buildVerificationEmailHtml — via send-verification-email with email_provider configured', () => { it('send-verification-email triggers email send when email_provider is configured', async () => { const { vi } = await import('vitest') diff --git a/server/auth.ts b/server/auth.ts index 89dfc874..6c89bc34 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -1,5 +1,5 @@ import { apiKey } from '@better-auth/api-key' -import { APIError, type BetterAuthPlugin, betterAuth } from 'better-auth' +import { APIError, type BetterAuthOptions, type BetterAuthPlugin, betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' import type { CaptchaOptions } from 'better-auth/plugins' @@ -39,6 +39,7 @@ import * as authSchema from './db/auth-schema' import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema' import { executeWriteTransaction } from './db/transaction' import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig } from './domain/captcha' +import { EMAIL_VERIFICATION_REQUIRED_OPTION_KEY, isEmailVerificationRequired } from './domain/email-verification' import { currentTrafficPeriod } from './domain/quota' import { recordAuditEffect } from './lib/audit' import { isLocalNetworkOrigin } from './lib/local-origin' @@ -130,6 +131,13 @@ function dynamicCaptcha(db: Database): BetterAuthPlugin { } } +const EMAIL_VERIFICATION_AUTH_PATHS = ['/sign-up/email', '/sign-in/email', '/sign-in/username'] as const + +function usesEmailVerificationPolicy(request: Request): boolean { + const path = new URL(request.url).pathname + return EMAIL_VERIFICATION_AUTH_PATHS.some((authPath) => path.endsWith(authPath)) +} + const _INVITE_CODE_ERRORS: Record = { not_found: 'Invalid invite code', already_used: 'Invite code already used', @@ -303,9 +311,10 @@ export async function createAuth( // db proxy in a binding-free Platform — matching the previous behaviour where // a Database source had no CF binding available. const authPlatform: Platform = platformProxy ?? { db: dbProxy, getEnv: () => undefined, getBinding: () => undefined } - const email = createEmailGateway(createSystemOptionsRepo(db)) + const systemOptionsRepo = createSystemOptionsRepo(db) + const email = createEmailGateway(systemOptionsRepo) const providerConfigs = await loadProviderConfigs(rawDb) - const auth = betterAuth({ + const authOptions = { database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }), secret, baseURL, @@ -668,15 +677,51 @@ export async function createAuth( }, }, }, - }) + } satisfies BetterAuthOptions - // betterAuth() starts its lazy $context init synchronously, inside whichever - // request constructs the instance. Resolve it here so a cached instance never - // carries a pending promise tied to its creating request — on Cloudflare - // Workers such a promise never settles when awaited from a later request, - // which would hang every auth call in the isolate. - await auth.$context - return auth + const buildAuth = (requireEmailVerification: boolean) => + betterAuth({ + ...authOptions, + emailAndPassword: { ...authOptions.emailAndPassword, requireEmailVerification }, + emailVerification: { + ...authOptions.emailVerification, + sendOnSignUp: requireEmailVerification, + sendOnSignIn: requireEmailVerification, + }, + }) + + const createAuthInstance = async (requireEmailVerification: boolean) => { + const auth = buildAuth(requireEmailVerification) + + // betterAuth() starts its lazy $context init synchronously, inside whichever + // request constructs the instance. Resolve it here so a cached instance never + // carries a pending promise tied to its creating request — on Cloudflare + // Workers such a promise never settles when awaited from a later request, + // which would hang every auth call in the isolate. + await auth.$context + return auth + } + + const defaultAuth = await createAuthInstance(false) + let verificationAuth: typeof defaultAuth | null = null + const dynamicHandler = async (request: Request): Promise => { + if (!usesEmailVerificationPolicy(request)) return defaultAuth.handler(request) + + const required = isEmailVerificationRequired( + await systemOptionsRepo.getValue(EMAIL_VERIFICATION_REQUIRED_OPTION_KEY), + ) + if (!required) return defaultAuth.handler(request) + + verificationAuth ??= await createAuthInstance(true) + return verificationAuth.handler(request) + } + + return new Proxy(defaultAuth, { + get(target, property) { + if (property === 'handler') return dynamicHandler + return Reflect.get(target, property, target) + }, + }) } export type Auth = Awaited> diff --git a/server/domain/email-verification.ts b/server/domain/email-verification.ts new file mode 100644 index 00000000..eece67e6 --- /dev/null +++ b/server/domain/email-verification.ts @@ -0,0 +1,5 @@ +export const EMAIL_VERIFICATION_REQUIRED_OPTION_KEY = 'auth_require_email_verification' + +export function isEmailVerificationRequired(value: string | null): boolean { + return value === 'true' +} diff --git a/server/http/site/email-config.integration.test.ts b/server/http/site/email-config.integration.test.ts index 0c64a760..998af55e 100644 --- a/server/http/site/email-config.integration.test.ts +++ b/server/http/site/email-config.integration.test.ts @@ -1,3 +1,4 @@ +import { eq } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' import * as schema from '../../db/schema.js' import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js' @@ -82,7 +83,7 @@ describe('Admin Email Config API — GET', () => { const res = await app.request('/api/site/email', { headers }) expect(res.status).toBe(200) const body = (await res.json()) as Record - expect(body).toEqual({ enabled: false, provider: null }) + expect(body).toEqual({ enabled: false, requireEmailVerification: false, provider: null }) }) it('returns enabled with null provider when email is enabled but sender/provider are incomplete [spec: email-config/incomplete-provider]', async () => { @@ -94,6 +95,7 @@ describe('Admin Email Config API — GET', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ enabled: true, + requireEmailVerification: false, provider: null, }) }) @@ -152,6 +154,7 @@ describe('Admin Email Config API — GET', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toEqual({ enabled: true, + requireEmailVerification: false, provider: null, }) }) @@ -168,6 +171,7 @@ describe('Admin Email Config API — PUT', () => { body: JSON.stringify({ provider: 'smtp', enabled: true, + requireEmailVerification: true, from: 'no-reply@example.com', smtp: { host: 'smtp.example.com', @@ -193,6 +197,7 @@ describe('Admin Email Config API — PUT', () => { body: JSON.stringify({ provider: 'smtp', enabled: true, + requireEmailVerification: false, from: 'sender@example.com', smtp: { host: 'mail.example.com', @@ -213,6 +218,34 @@ describe('Admin Email Config API — PUT', () => { expect(smtp.port).toBe(465) }) + it('preserves the SMTP password when only settings around it are changed', async () => { + const { app, db } = await createTestApp() + const headers = await adminHeaders(app) + await seedSmtpConfig(db) + + const currentResponse = await app.request('/api/site/email', { headers }) + const current = (await currentResponse.json()) as { + smtp: { host: string; port: number; user: string; pass: string; secure: boolean } + } + await app.request('/api/site/email', { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider: 'smtp', + enabled: true, + requireEmailVerification: true, + from: 'no-reply@example.com', + smtp: current.smtp, + }), + }) + + const [stored] = await db + .select({ value: schema.systemOptions.value }) + .from(schema.systemOptions) + .where(eq(schema.systemOptions.key, 'email_smtp_pass')) + expect(stored.value).toBe('supersecret') + }) + it('saves HTTP config and returns success [spec: email-config/save-http]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -223,6 +256,7 @@ describe('Admin Email Config API — PUT', () => { body: JSON.stringify({ provider: 'http', enabled: true, + requireEmailVerification: false, from: 'no-reply@example.com', http: { url: 'https://api.mail.example.com/send', @@ -245,6 +279,7 @@ describe('Admin Email Config API — PUT', () => { body: JSON.stringify({ provider: 'http', enabled: true, + requireEmailVerification: false, from: 'http-from@example.com', http: { url: 'https://api.sendgrid.com/v3/mail/send', @@ -285,6 +320,25 @@ describe('Admin Email Config API — PUT', () => { expect(res.status).toBe(400) }) + it('rejects required verification when email delivery is disabled', async () => { + const { app } = await createTestApp() + const headers = await adminHeaders(app) + + const res = await app.request('/api/site/email', { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: false, + requireEmailVerification: true, + provider: 'smtp', + from: 'sender@example.com', + smtp: { host: 'mail.example.com', port: 587, user: '', pass: '', secure: true }, + }), + }) + + expect(res.status).toBe(400) + }) + it('updates existing config when PUT is called a second time [spec: email-config/update]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) @@ -295,6 +349,7 @@ describe('Admin Email Config API — PUT', () => { body: JSON.stringify({ provider: 'smtp', enabled: true, + requireEmailVerification: false, from: 'first@example.com', smtp: { host: 'first.smtp.com', port: 25, user: '', pass: '', secure: false }, }), @@ -306,6 +361,7 @@ describe('Admin Email Config API — PUT', () => { body: JSON.stringify({ provider: 'smtp', enabled: true, + requireEmailVerification: false, from: 'second@example.com', smtp: { host: 'second.smtp.com', port: 587, user: '', pass: '', secure: true }, }), @@ -329,6 +385,7 @@ describe('Admin Email Config API — PUT', () => { body: JSON.stringify({ provider: 'cloudflare', enabled: true, + requireEmailVerification: false, from: 'no-reply@zpan.space', }), }) @@ -346,6 +403,7 @@ describe('Admin Email Config API — PUT', () => { body: JSON.stringify({ provider: 'cloudflare', enabled: true, + requireEmailVerification: true, from: 'no-reply@zpan.space', }), }) @@ -353,6 +411,7 @@ describe('Admin Email Config API — PUT', () => { const res = await app.request('/api/site/email', { headers }) await expect(res.json()).resolves.toEqual({ enabled: true, + requireEmailVerification: true, provider: 'cloudflare', from: 'no-reply@zpan.space', }) @@ -367,6 +426,7 @@ describe('Admin Email Config API — PUT', () => { headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false, + requireEmailVerification: false, provider: 'smtp', from: 'sender@example.com', smtp: { host: 'mail.example.com', port: 587, user: '', pass: '', secure: true }, @@ -448,6 +508,7 @@ describe('Admin Email Config API — POST /test', () => { headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false, + requireEmailVerification: false, provider: 'http', from: 'no-reply@example.com', http: { url: 'https://api.mail.example.com/send', apiKey: 'key' }, diff --git a/server/http/site/email-config.ts b/server/http/site/email-config.ts index 50c2b4b3..3b978e38 100644 --- a/server/http/site/email-config.ts +++ b/server/http/site/email-config.ts @@ -6,6 +6,7 @@ import { errorResponse, jsonContent } from '../openapi' const smtpConfigSchema = z.object({ enabled: z.boolean(), + requireEmailVerification: z.boolean(), provider: z.literal('smtp'), from: z.string().email(), smtp: z.object({ @@ -19,6 +20,7 @@ const smtpConfigSchema = z.object({ const httpConfigSchema = z.object({ enabled: z.boolean(), + requireEmailVerification: z.boolean(), provider: z.literal('http'), from: z.string().email(), http: z.object({ url: z.string().url(), apiKey: z.string().min(1) }), @@ -26,6 +28,7 @@ const httpConfigSchema = z.object({ const cloudflareConfigSchema = z.object({ enabled: z.boolean(), + requireEmailVerification: z.boolean(), provider: z.literal('cloudflare'), from: z.string().email(), }) @@ -36,7 +39,12 @@ const emailConfigSchema = z.discriminatedUnion('provider', [smtpConfigSchema, ht // with the stable fields; provider-specific masked settings vary and are not // modeled field-for-field (no `additionalProperties` — oapi-codegen mishandles it). const emailSettingsSchema = z - .object({ enabled: z.boolean(), provider: z.string().nullable().optional(), from: z.string().optional() }) + .object({ + enabled: z.boolean(), + requireEmailVerification: z.boolean(), + provider: z.string().nullable().optional(), + from: z.string().optional(), + }) .openapi('EmailSettings') const testEmailSchema = z.object({ to: z.string().email() }) @@ -61,7 +69,10 @@ const saveRoute = createRoute({ path: '/', middleware: [requireAdmin] as const, request: { body: { content: { 'application/json': { schema: emailConfigSchema } }, required: true } }, - responses: { 200: jsonContent(successSchema, 'Saved') }, + responses: { + 200: jsonContent(successSchema, 'Saved'), + 400: errorResponse('Invalid email configuration'), + }, }) const testRoute = createRoute({ diff --git a/server/usecases/site/email-config.test.ts b/server/usecases/site/email-config.test.ts index 83a57fe2..0e6eee95 100644 --- a/server/usecases/site/email-config.test.ts +++ b/server/usecases/site/email-config.test.ts @@ -67,12 +67,20 @@ describe('email-config usecase', () => { describe('getEmailConfig', () => { it('returns the disabled empty state when no config exists', async () => { const { deps } = makeDeps({ email: { getSettings: async () => ({ enabled: false, config: null }) } }) - expect(await getEmailConfig(deps, platform)).toEqual({ enabled: false, provider: null }) + expect(await getEmailConfig(deps, platform)).toEqual({ + enabled: false, + requireEmailVerification: false, + provider: null, + }) }) it('returns enabled with provider null when config is incomplete', async () => { const { deps } = makeDeps({ email: { getSettings: async () => ({ enabled: true, config: null }) } }) - expect(await getEmailConfig(deps, platform)).toEqual({ enabled: true, provider: null }) + expect(await getEmailConfig(deps, platform)).toEqual({ + enabled: true, + requireEmailVerification: false, + provider: null, + }) }) it('masks the SMTP password, keeping the last 4 chars', async () => { @@ -80,6 +88,7 @@ describe('email-config usecase', () => { const out = await getEmailConfig(deps, platform) expect(out).toEqual({ enabled: true, + requireEmailVerification: false, provider: 'smtp', from: 'no-reply@example.com', smtp: { @@ -97,6 +106,7 @@ describe('email-config usecase', () => { const out = await getEmailConfig(deps, platform) expect(out).toEqual({ enabled: true, + requireEmailVerification: false, provider: 'http', from: 'no-reply@example.com', http: { url: 'https://api.mail.example.com/send', apiKey: '****-key' }, @@ -107,6 +117,7 @@ describe('email-config usecase', () => { const { deps } = makeDeps({ email: { getSettings: async () => cloudflareSettings } }) expect(await getEmailConfig(deps, platform)).toEqual({ enabled: true, + requireEmailVerification: false, provider: 'cloudflare', from: 'no-reply@zpan.space', }) @@ -125,6 +136,14 @@ describe('email-config usecase', () => { const out = (await getEmailConfig(deps, platform)) as unknown as { smtp: { pass: string } } expect(out.smtp.pass).toBe('') }) + + it('returns the stored email verification policy', async () => { + const { deps } = makeDeps({ + email: { getSettings: async () => cloudflareSettings }, + systemOptions: { getValue: async () => 'true' }, + }) + expect(await getEmailConfig(deps, platform)).toMatchObject({ requireEmailVerification: true }) + }) }) describe('saveEmailConfig', () => { @@ -132,6 +151,7 @@ describe('email-config usecase', () => { const { deps, setMany } = makeDeps() const input: SaveEmailConfigInput = { enabled: true, + requireEmailVerification: true, provider: 'smtp', from: 'sender@example.com', smtp: { host: 'mail.example.com', port: 465, user: 'u', pass: 'p', secure: false }, @@ -139,6 +159,7 @@ describe('email-config usecase', () => { await saveEmailConfig(deps, input) expect(setMany).toHaveBeenCalledWith([ { key: 'email_enabled', value: 'true' }, + { key: 'auth_require_email_verification', value: 'true' }, { key: 'email_provider', value: 'smtp' }, { key: 'email_from', value: 'sender@example.com' }, { key: 'email_smtp_host', value: 'mail.example.com' }, @@ -153,6 +174,7 @@ describe('email-config usecase', () => { const { deps, setMany } = makeDeps() const input: SaveEmailConfigInput = { enabled: true, + requireEmailVerification: false, provider: 'http', from: 'http-from@example.com', http: { url: 'https://api.sendgrid.com/v3/mail/send', apiKey: 'SG.key12345' }, @@ -160,6 +182,7 @@ describe('email-config usecase', () => { await saveEmailConfig(deps, input) expect(setMany).toHaveBeenCalledWith([ { key: 'email_enabled', value: 'true' }, + { key: 'auth_require_email_verification', value: 'false' }, { key: 'email_provider', value: 'http' }, { key: 'email_from', value: 'http-from@example.com' }, { key: 'email_http_url', value: 'https://api.sendgrid.com/v3/mail/send' }, @@ -167,12 +190,46 @@ describe('email-config usecase', () => { ]) }) + it('preserves the stored SMTP password when the masked value is submitted unchanged', async () => { + const { deps, setMany } = makeDeps({ systemOptions: { getValue: async () => 'supersecret' } }) + await saveEmailConfig(deps, { + enabled: true, + requireEmailVerification: true, + provider: 'smtp', + from: 'sender@example.com', + smtp: { host: 'mail.example.com', port: 587, user: 'u', pass: '****cret', secure: true }, + }) + + expect(setMany).toHaveBeenCalledWith(expect.arrayContaining([{ key: 'email_smtp_pass', value: 'supersecret' }])) + }) + + it('preserves the stored HTTP API key when the masked value is submitted unchanged', async () => { + const { deps, setMany } = makeDeps({ systemOptions: { getValue: async () => 'my-secret-key' } }) + await saveEmailConfig(deps, { + enabled: true, + requireEmailVerification: true, + provider: 'http', + from: 'sender@example.com', + http: { url: 'https://api.mail.example.com/send', apiKey: '****-key' }, + }) + + expect(setMany).toHaveBeenCalledWith( + expect.arrayContaining([{ key: 'email_http_api_key', value: 'my-secret-key' }]), + ) + }) + it('writes only the three shared rows for Cloudflare', async () => { const { deps, setMany } = makeDeps() - const input: SaveEmailConfigInput = { enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' } + const input: SaveEmailConfigInput = { + enabled: true, + requireEmailVerification: false, + provider: 'cloudflare', + from: 'no-reply@zpan.space', + } await saveEmailConfig(deps, input) expect(setMany).toHaveBeenCalledWith([ { key: 'email_enabled', value: 'true' }, + { key: 'auth_require_email_verification', value: 'false' }, { key: 'email_provider', value: 'cloudflare' }, { key: 'email_from', value: 'no-reply@zpan.space' }, ]) @@ -182,6 +239,7 @@ describe('email-config usecase', () => { const { deps, setMany } = makeDeps() const input: SaveEmailConfigInput = { enabled: false, + requireEmailVerification: false, provider: 'smtp', from: 'sender@example.com', smtp: { host: 'mail.example.com', port: 587, user: '', pass: '', secure: true }, @@ -194,6 +252,19 @@ describe('email-config usecase', () => { ]), ) }) + + it('rejects required verification when email sending is disabled', async () => { + const { deps, setMany } = makeDeps() + await expect( + saveEmailConfig(deps, { + enabled: false, + requireEmailVerification: true, + provider: 'cloudflare', + from: 'no-reply@zpan.space', + }), + ).rejects.toMatchObject({ httpStatus: 400 }) + expect(setMany).not.toHaveBeenCalled() + }) }) describe('sendTestEmail', () => { diff --git a/server/usecases/site/email-config.ts b/server/usecases/site/email-config.ts index ea276136..16f8087d 100644 --- a/server/usecases/site/email-config.ts +++ b/server/usecases/site/email-config.ts @@ -9,6 +9,7 @@ // EmailGateway reads them back (and decides whether email is "configured"). // All reads/writes go through the ports — nothing here touches infrastructure. +import { EMAIL_VERIFICATION_REQUIRED_OPTION_KEY, isEmailVerificationRequired } from '../../domain/email-verification' import type { Platform } from '../../platform/interface' import { type AppError, @@ -28,13 +29,22 @@ export type EmailConfigDeps = { // The validated request body the http layer hands to saveEmailConfig — the same // discriminated union the route's zod schema produces. export type SaveEmailConfigInput = - | { enabled: boolean; provider: 'smtp'; from: string; smtp: SmtpConfig } - | { enabled: boolean; provider: 'http'; from: string; http: { url: string; apiKey: string } } - | { enabled: boolean; provider: 'cloudflare'; from: string } + | { enabled: boolean; requireEmailVerification: boolean; provider: 'smtp'; from: string; smtp: SmtpConfig } + | { + enabled: boolean + requireEmailVerification: boolean + provider: 'http' + from: string + http: { url: string; apiKey: string } + } + | { enabled: boolean; requireEmailVerification: boolean; provider: 'cloudflare'; from: string } // The GET response shape: the enabled flag plus either a secret-masked view of // the stored config or `{ provider: null }` when no usable config exists. -export type MaskedEmailSettings = { enabled: boolean } & (Record | { provider: null }) +export type MaskedEmailSettings = { enabled: boolean; requireEmailVerification: boolean } & ( + | Record + | { provider: null } +) // A test send either succeeds or fails for a reportable reason (provider error, // or email being disabled — the gateway throws for both). A failure becomes a 400 @@ -78,9 +88,14 @@ function maskConfig(config: EmailConfig): Record { // Flatten a validated config into the `email_*` system-option rows. The first // three rows are written for every provider; provider-specific rows follow. -function configEntries(input: SaveEmailConfigInput): [string, string][] { +function preserveMaskedSecret(input: string, existing: string | null): string { + return existing !== null && input === maskSecret(existing) ? existing : input +} + +function configEntries(input: SaveEmailConfigInput, existingSecret: string | null): [string, string][] { const entries: [string, string][] = [ ['email_enabled', String(input.enabled)], + [EMAIL_VERIFICATION_REQUIRED_OPTION_KEY, String(input.requireEmailVerification)], ['email_provider', input.provider], ['email_from', input.from], ] @@ -89,22 +104,29 @@ function configEntries(input: SaveEmailConfigInput): [string, string][] { ['email_smtp_host', input.smtp.host], ['email_smtp_port', String(input.smtp.port)], ['email_smtp_user', input.smtp.user], - ['email_smtp_pass', input.smtp.pass], + ['email_smtp_pass', preserveMaskedSecret(input.smtp.pass, existingSecret)], ['email_smtp_secure', String(input.smtp.secure)], ) } else if (input.provider === 'http') { - entries.push(['email_http_url', input.http.url], ['email_http_api_key', input.http.apiKey]) + entries.push( + ['email_http_url', input.http.url], + ['email_http_api_key', preserveMaskedSecret(input.http.apiKey, existingSecret)], + ) } return entries } export async function getEmailConfig( - deps: Pick, + deps: Pick, platform: Platform, ): Promise { - const settings = await deps.email.getSettings(platform) + const [settings, requiredValue] = await Promise.all([ + deps.email.getSettings(platform), + deps.systemOptions.getValue(EMAIL_VERIFICATION_REQUIRED_OPTION_KEY), + ]) return { enabled: settings.enabled, + requireEmailVerification: isEmailVerificationRequired(requiredValue), ...(settings.config ? maskConfig(settings.config) : { provider: null }), } } @@ -113,7 +135,14 @@ export async function saveEmailConfig( deps: Pick, input: SaveEmailConfigInput, ): Promise { - await deps.systemOptions.setMany(configEntries(input).map(([key, value]) => ({ key, value }))) + if (input.requireEmailVerification && !input.enabled) { + throw badRequest('Email must be enabled before email verification can be required') + } + const existingSecret = + input.provider === 'cloudflare' + ? null + : await deps.systemOptions.getValue(input.provider === 'smtp' ? 'email_smtp_pass' : 'email_http_api_key') + await deps.systemOptions.setMany(configEntries(input, existingSecret).map(([key, value]) => ({ key, value }))) } export async function sendTestEmail( diff --git a/src/components/admin/email-config-section.tsx b/src/components/admin/email-config-section.tsx index 3480b16c..eb63372f 100644 --- a/src/components/admin/email-config-section.tsx +++ b/src/components/admin/email-config-section.tsx @@ -21,6 +21,7 @@ type EmailConfigResponse = Awaited> interface FormState { enabled: boolean + requireEmailVerification: boolean provider: ProviderType from: string smtpHost: string @@ -34,6 +35,7 @@ interface FormState { const emptyForm: FormState = { enabled: false, + requireEmailVerification: false, provider: 'smtp', from: '', smtpHost: '', @@ -50,6 +52,7 @@ function formToPayload(form: FormState): EmailConfigData { return { provider: 'smtp', enabled: form.enabled, + requireEmailVerification: form.requireEmailVerification, from: form.from, smtp: { host: form.smtpHost, @@ -64,12 +67,14 @@ function formToPayload(form: FormState): EmailConfigData { return { provider: 'cloudflare', enabled: form.enabled, + requireEmailVerification: form.requireEmailVerification, from: form.from, } } return { provider: 'http', enabled: form.enabled, + requireEmailVerification: form.requireEmailVerification, from: form.from, http: { url: form.httpUrl, apiKey: form.httpApiKey }, } @@ -77,12 +82,19 @@ function formToPayload(form: FormState): EmailConfigData { function formStateFromConfig(data: EmailConfigResponse | undefined): FormState { if (!data) return emptyForm - if (data.provider === null) return { ...emptyForm, enabled: data.enabled } + if (data.provider === null) { + return { + ...emptyForm, + enabled: data.enabled, + requireEmailVerification: data.requireEmailVerification, + } + } const config = data as EmailConfigData if (config.provider === 'smtp') { return { enabled: config.enabled, + requireEmailVerification: config.requireEmailVerification, provider: 'smtp', from: config.from, smtpHost: config.smtp.host, @@ -98,6 +110,7 @@ function formStateFromConfig(data: EmailConfigResponse | undefined): FormState { if (config.provider === 'http') { return { enabled: config.enabled, + requireEmailVerification: config.requireEmailVerification, provider: 'http', from: config.from, smtpHost: '', @@ -112,6 +125,7 @@ function formStateFromConfig(data: EmailConfigResponse | undefined): FormState { return { enabled: config.enabled, + requireEmailVerification: config.requireEmailVerification, provider: 'cloudflare', from: config.from, smtpHost: '', @@ -202,6 +216,9 @@ export function EmailConfigSection() { {savedForm.enabled ? t('admin.auth.enabled') : t('common.disabled')} + {savedForm.requireEmailVerification && ( + {t('admin.auth.emailVerificationRequired')} + )} @@ -242,7 +259,23 @@ export function EmailConfigSection() { {t('admin.auth.emailEnabled')} - update({ enabled: !!v })} /> + update({ enabled, ...(!enabled ? { requireEmailVerification: false } : {}) })} + /> + + +
+ + {t('admin.auth.emailVerificationRequired')} + + update({ requireEmailVerification })} + />
diff --git a/src/i18n/admin-auth-locale.test.ts b/src/i18n/admin-auth-locale.test.ts index a7989468..608c8e34 100644 --- a/src/i18n/admin-auth-locale.test.ts +++ b/src/i18n/admin-auth-locale.test.ts @@ -75,6 +75,8 @@ const ADMIN_AUTH_KEYS = [ 'admin.auth.emailSection', 'admin.auth.emailEnabled', 'admin.auth.emailEnabledHint', + 'admin.auth.emailVerificationRequired', + 'admin.auth.emailVerificationHint', 'admin.auth.emailProvider', 'admin.auth.emailProviderPlaceholder', 'admin.auth.emailCloudflare', diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 29fbea1e..067dc0c3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -46,6 +46,9 @@ "auth.resetSuccess": "Password reset. You can now sign in.", "auth.resetFailed": "Could not reset password. The link may have expired.", "auth.resetTokenMissing": "This reset link is invalid or has expired.", + "auth.verifyEmailTitle": "Check your email", + "auth.verifyEmailSent": "We sent a verification link to {{email}}. Open it to finish creating your account.", + "auth.emailNotVerified": "Your email is not verified. We sent a new verification link.", "nav.main": "Main", "nav.files": "My Files", "nav.shares": "Shares", @@ -923,6 +926,8 @@ "admin.auth.emailSection": "Email Configuration", "admin.auth.emailEnabled": "Enable Email", "admin.auth.emailEnabledHint": "Controls whether any email provider is allowed to send messages.", + "admin.auth.emailVerificationRequired": "Require email verification", + "admin.auth.emailVerificationHint": "Email and password users must open the verification link before they can sign in.", "admin.auth.emailProvider": "Provider Type", "admin.auth.emailProviderPlaceholder": "Select email provider", "admin.auth.emailCloudflare": "Cloudflare Email", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1a2c2ca5..d086ee70 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -46,6 +46,9 @@ "auth.resetSuccess": "密码已重置,现在可以登录了。", "auth.resetFailed": "无法重置密码,链接可能已过期。", "auth.resetTokenMissing": "该重置链接无效或已过期。", + "auth.verifyEmailTitle": "检查你的邮箱", + "auth.verifyEmailSent": "验证邮件已发送至 {{email}}。请点击邮件中的链接完成注册。", + "auth.emailNotVerified": "邮箱尚未验证,我们已重新发送验证邮件。", "nav.main": "主要", "nav.files": "我的文件", "nav.shares": "我的分享", @@ -923,6 +926,8 @@ "admin.auth.emailSection": "邮件配置", "admin.auth.emailEnabled": "启用邮件", "admin.auth.emailEnabledHint": "控制是否允许任何邮件提供商发送邮件。", + "admin.auth.emailVerificationRequired": "邮箱注册必须验证", + "admin.auth.emailVerificationHint": "开启后,邮箱密码用户必须点击验证邮件中的链接才能登录。", "admin.auth.emailProvider": "提供商类型", "admin.auth.emailProviderPlaceholder": "选择邮件提供商", "admin.auth.emailCloudflare": "Cloudflare 邮件", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index c63a8af6..fd66d850 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -3787,7 +3787,12 @@ describe('api', () => { describe('email config API', () => { it('getEmailConfig fetches admin email config', async () => { - const payload = { enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' } + const payload = { + enabled: true, + requireEmailVerification: true, + provider: 'cloudflare', + from: 'no-reply@zpan.space', + } vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) const result = await getEmailConfig() @@ -3801,7 +3806,12 @@ describe('api', () => { it('saveEmailConfig PUTs the expected payload', async () => { vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ success: true })) - const payload = { enabled: true, provider: 'cloudflare' as const, from: 'no-reply@zpan.space' } + const payload = { + enabled: true, + requireEmailVerification: true, + provider: 'cloudflare' as const, + from: 'no-reply@zpan.space', + } await saveEmailConfig(payload) const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] @@ -3814,7 +3824,12 @@ describe('api', () => { vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'bad config' }, false, 400)) await expect( - saveEmailConfig({ enabled: true, provider: 'cloudflare', from: 'no-reply@zpan.space' }), + saveEmailConfig({ + enabled: true, + requireEmailVerification: false, + provider: 'cloudflare', + from: 'no-reply@zpan.space', + }), ).rejects.toThrow('bad config') }) diff --git a/src/lib/api.ts b/src/lib/api.ts index a9cbc49b..79e8c579 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -794,6 +794,7 @@ export function getSiteInvitation(token: string) { export interface SmtpEmailConfig { enabled: boolean + requireEmailVerification: boolean provider: 'smtp' from: string smtp: { host: string; port: number; user: string; pass: string; secure: boolean } @@ -801,6 +802,7 @@ export interface SmtpEmailConfig { export interface HttpEmailConfig { enabled: boolean + requireEmailVerification: boolean provider: 'http' from: string http: { url: string; apiKey: string } @@ -808,6 +810,7 @@ export interface HttpEmailConfig { export interface CloudflareEmailConfig { enabled: boolean + requireEmailVerification: boolean provider: 'cloudflare' from: string } @@ -816,6 +819,7 @@ export type EmailConfigData = SmtpEmailConfig | HttpEmailConfig | CloudflareEmai export interface EmptyEmailConfigData { enabled: boolean + requireEmailVerification: boolean provider: null } diff --git a/src/routes/(auth)/sign-in.tsx b/src/routes/(auth)/sign-in.tsx index 87a47982..d121a07a 100644 --- a/src/routes/(auth)/sign-in.tsx +++ b/src/routes/(auth)/sign-in.tsx @@ -55,7 +55,11 @@ function SignIn() { : await signIn.username({ username: identity, password, callbackURL: '/files', fetchOptions }) if (result.error) { - setError(result.error.message ?? t('auth.signInFailed')) + setError( + result.error.code === 'EMAIL_NOT_VERIFIED' + ? t('auth.emailNotVerified') + : (result.error.message ?? t('auth.signInFailed')), + ) return } if (redirectTo) { diff --git a/src/routes/(auth)/sign-up.tsx b/src/routes/(auth)/sign-up.tsx index 54f087b2..fbd0d03c 100644 --- a/src/routes/(auth)/sign-up.tsx +++ b/src/routes/(auth)/sign-up.tsx @@ -36,6 +36,7 @@ function SignUp() { const [password, setPassword] = useState('') const [inviteCode, setInviteCode] = useState('') const [error, setError] = useState('') + const [verificationSentTo, setVerificationSentTo] = useState(null) const [loading, setLoading] = useState(false) const [formExpanded, setFormExpanded] = useState(providers.length <= 3) const [captchaToken, setCaptchaToken] = useState('') @@ -124,6 +125,22 @@ function SignUp() { ) } + if (verificationSentTo) { + return ( +
+
+
+

{t('auth.verifyEmailTitle')}

+

{t('auth.verifyEmailSent', { email: verificationSentTo })}

+
+ +
+
+ ) + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError('') @@ -143,6 +160,10 @@ function SignUp() { setError(result.error.message ?? t('auth.signUpFailed')) return } + if (result.data?.token === null) { + setVerificationSentTo(email) + return + } navigate({ to: '/files' }) } finally { setLoading(false)