mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(auth): add configurable email verification
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -20,6 +20,17 @@ async function signUp(ctx: TestCtx, email: string, extra?: Record<string, unknow
|
||||
})
|
||||
}
|
||||
|
||||
async function configureRequiredEmailVerification(ctx: TestCtx) {
|
||||
await ctx.db.insert(schema.systemOptions).values([
|
||||
{ key: 'email_enabled', value: 'true' },
|
||||
{ key: 'email_provider', value: 'http' },
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
|
||||
{ key: 'email_http_api_key', value: 'my-api-key' },
|
||||
{ key: 'auth_require_email_verification', value: 'true' },
|
||||
])
|
||||
}
|
||||
|
||||
async function expectPlanEntitlement(ctx: TestCtx, resourceType: 'storage' | 'traffic', bytes: number) {
|
||||
const rows = await ctx.db.select().from(schema.orgQuotaEntitlements)
|
||||
expect(rows).toEqual(
|
||||
@@ -301,6 +312,100 @@ describe('isEmailConfigured — via emailVerification conditional', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
+56
-11
@@ -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<string, string> = {
|
||||
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<Response> => {
|
||||
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<ReturnType<typeof createAuth>>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
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' },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown> | { provider: null })
|
||||
export type MaskedEmailSettings = { enabled: boolean; requireEmailVerification: boolean } & (
|
||||
| Record<string, unknown>
|
||||
| { 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<string, unknown> {
|
||||
|
||||
// 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<EmailConfigDeps, 'email'>,
|
||||
deps: Pick<EmailConfigDeps, 'email' | 'systemOptions'>,
|
||||
platform: Platform,
|
||||
): Promise<MaskedEmailSettings> {
|
||||
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<EmailConfigDeps, 'systemOptions'>,
|
||||
input: SaveEmailConfigInput,
|
||||
): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -21,6 +21,7 @@ type EmailConfigResponse = Awaited<ReturnType<typeof getEmailConfig>>
|
||||
|
||||
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() {
|
||||
<Badge variant={savedForm.enabled ? 'default' : 'secondary'}>
|
||||
{savedForm.enabled ? t('admin.auth.enabled') : t('common.disabled')}
|
||||
</Badge>
|
||||
{savedForm.requireEmailVerification && (
|
||||
<Badge variant="secondary">{t('admin.auth.emailVerificationRequired')}</Badge>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setTestDialogOpen(true)} disabled={!savedForm.enabled}>
|
||||
{t('admin.auth.testEmail')}
|
||||
</Button>
|
||||
@@ -242,7 +259,23 @@ export function EmailConfigSection() {
|
||||
<AdminFormLabel htmlFor="emailEnabled" help={t('admin.auth.emailEnabledHint')}>
|
||||
{t('admin.auth.emailEnabled')}
|
||||
</AdminFormLabel>
|
||||
<Switch id="emailEnabled" checked={form.enabled} onCheckedChange={(v) => update({ enabled: !!v })} />
|
||||
<Switch
|
||||
id="emailEnabled"
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(enabled) => update({ enabled, ...(!enabled ? { requireEmailVerification: false } : {}) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<AdminFormLabel htmlFor="requireEmailVerification" help={t('admin.auth.emailVerificationHint')}>
|
||||
{t('admin.auth.emailVerificationRequired')}
|
||||
</AdminFormLabel>
|
||||
<Switch
|
||||
id="requireEmailVerification"
|
||||
checked={form.requireEmailVerification}
|
||||
disabled={!form.enabled}
|
||||
onCheckedChange={(requireEmailVerification) => update({ requireEmailVerification })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminFormField id="email-provider" label={t('admin.auth.emailProvider')} required>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 邮件",
|
||||
|
||||
+18
-3
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -36,6 +36,7 @@ function SignUp() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [inviteCode, setInviteCode] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [verificationSentTo, setVerificationSentTo] = useState<string | null>(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 (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6 p-6 text-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold">{t('auth.verifyEmailTitle')}</h1>
|
||||
<p className="text-muted-foreground">{t('auth.verifyEmailSent', { email: verificationSentTo })}</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/sign-in">{t('auth.backToSignIn')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user