feat: add ability to force email verification (#1837)

This commit is contained in:
Elias Schneider
2026-10-13 22:04:33 +02:00
committed by GitHub
parent eb8ea6bed2
commit 0ffeb170cf
81 changed files with 2310 additions and 263 deletions
+17 -1
View File
@@ -567,7 +567,23 @@
"email_verification_warning": "Verify your email address",
"email_verification_warning_description": "Your email address is not verified yet. Please verify it as soon as possible.",
"email_verification": "Email Verification",
"email_verification_description": "Send a verification email to users when they sign up or change their email address.",
"email_verification_description": "Choose whether users verify their email address and whether unverified accounts are restricted.",
"email_verification_disabled_description": "Users are not asked to verify their email address.",
"email_verification_optional": "Optional",
"email_verification_optional_description": "Users can verify their email address, but unverified accounts keep full access.",
"email_verification_required": "Required",
"email_verification_required_description": "Users can't sign in to applications until they verify their email address. Self-signed-up accounts that aren't verified within 24 hours are deleted.",
"email_verification_required_needs_user_email": "Requiring email verification also requires users to have an email address.",
"email_verification_required_warning": "Verify your email address to use your account",
"email_verification_required_warning_description": "Your access is limited until you verify your email address.",
"email_verification_deadline_description": "If you don't, your account will be deleted {time}.",
"verify_email_address": "Verify email address",
"email_verification_required_error": "You must verify your email address before you can sign in to applications",
"email_change_not_started": "Your other changes were saved, but the email to confirm your new email address couldn't be sent. Please try again.",
"email_change_pending_title": "Confirm your new email address",
"email_change_pending_description": "We sent a confirmation link to {email}. Your current email address stays in use until you confirm the change.",
"cancel_email_change": "Cancel change",
"email_change_canceled": "Email change canceled.",
"email_verification_success_title": "Email Verified Successfully",
"email_verification_success_description": "Your email address has been verified successfully.",
"email_verification_error_title": "Email Verification Failed",
@@ -7,8 +7,10 @@
import UserService from '#lib/services/user-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import userStore from '#lib/stores/user-store.ts';
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { LucideAlertTriangle, LucideCheckCircle2, LucideCircleX } from '@lucide/svelte';
import { formatDistanceToNow } from 'date-fns';
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { get } from 'svelte/store';
@@ -17,6 +19,10 @@
let emailVerificationState = $state(page.url.searchParams.get('emailVerificationState'));
const emailVerificationRequired = $derived(
isEmailVerificationPending($userStore, $appConfigStore)
);
async function sendEmailVerification() {
await userService
.sendEmailVerification()
@@ -60,15 +66,27 @@
</Alert.Description>
</Alert.Root>
{/if}
{:else if $userStore && $appConfigStore.emailVerificationEnabled && !$userStore.emailVerified}
<Alert.Root variant="warning" class="flex gap-3">
<LucideAlertTriangle class="size-4" />
{:else if $userStore && $appConfigStore.emailVerification !== 'disabled' && !$userStore.emailVerified}
<Alert.Root variant={emailVerificationRequired ? 'destructive' : 'warning'} class="flex gap-3">
<LucideAlertTriangle class="size-4 shrink-0" />
<div class="md:flex md:w-full md:place-content-between">
<div>
<Alert.Title class="font-semibold">{m.email_verification_warning()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_warning_description()}
</Alert.Description>
{#if emailVerificationRequired}
<Alert.Title class="font-semibold">{m.email_verification_required_warning()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_required_warning_description()}
{#if $userStore.emailVerificationDeadline}
{m.email_verification_deadline_description({
time: formatDistanceToNow($userStore.emailVerificationDeadline, { addSuffix: true })
})}
{/if}
</Alert.Description>
{:else}
<Alert.Title class="font-semibold">{m.email_verification_warning()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_verification_warning_description()}
</Alert.Description>
{/if}
</div>
<div>
<Button class="mt-2 md:mt-0" usePromiseLoading onclick={sendEmailVerification}>
@@ -12,9 +12,6 @@
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn(
'flex-1 text-sm outline-none',
className
)}
class={cn('flex-1 text-sm outline-none', className)}
{...restProps}
/>
+16 -1
View File
@@ -3,7 +3,13 @@ import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type
import type { Passkey } from '#lib/types/passkey.type.ts';
import type { SignupToken } from '#lib/types/signup-token.type.ts';
import type { UserGroup } from '#lib/types/user-group.type.ts';
import type { AccountUpdate, User, UserCreate, UserSignUp } from '#lib/types/user.type.ts';
import type {
AccountUpdate,
EmailChange,
User,
UserCreate,
UserSignUp
} from '#lib/types/user.type.ts';
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
import { get } from 'svelte/store';
import APIService from './api-service';
@@ -145,4 +151,13 @@ export default class UserService extends APIService {
const res = await this.api.post('/users/me/verify-email', { token });
return res.data as User;
};
getPendingEmailChange = async () => {
const res = await this.api.get('/users/me/email-change');
return res.status === 204 ? null : (res.data as EmailChange);
};
cancelEmailChange = async () => {
await this.api.delete('/users/me/email-change');
};
}
@@ -7,7 +7,7 @@ export type AppConfig = {
allowUserSignups: 'disabled' | 'withToken' | 'open';
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
emailOneTimeAccessAsAdminEnabled: boolean;
emailVerificationEnabled: boolean;
emailVerification: 'disabled' | 'optional' | 'required';
ldapEnabled: boolean;
disableAnimations: boolean;
uiConfigDisabled: boolean;
+10 -1
View File
@@ -16,9 +16,18 @@ export type User = {
locale?: Locale;
ldapId?: string;
disabled?: boolean;
emailVerificationDeadline?: string;
};
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
export type EmailChange = {
email: string;
expiresAt: string;
};
export type UserCreate = Omit<
User,
'id' | 'customClaims' | 'ldapId' | 'userGroups' | 'emailVerificationDeadline'
>;
export type AccountUpdate = Omit<UserCreate, 'isAdmin' | 'disabled' | 'emailVerified'>;
@@ -0,0 +1,13 @@
import type { AppConfig } from '#lib/types/application-configuration.type.ts';
import type { User } from '#lib/types/user.type.ts';
// Reports whether the user must verify their email before they can use their account
// Mirrors the backend rule: only regular users are restricted, and only while verification is required
export function isEmailVerificationPending(
user: User | null | undefined,
appConfig: AppConfig | null | undefined
) {
return (
!!user && appConfig?.emailVerification === 'required' && !user.isAdmin && !user.emailVerified
);
}
+7 -1
View File
@@ -20,7 +20,9 @@ const codeMessages: Record<string, () => string> = {
webauthn_authentication_failed: () => m.passkey_verification_failed(),
passkey_user_verification_required: () => m.passkey_user_verification_required(),
synced_passkey_not_allowed: () => m.synced_passkeys_not_allowed(),
device_login_expired: () => m.device_login_request_expired()
device_login_expired: () => m.device_login_request_expired(),
email_verification_required: () => m.email_verification_required_error(),
email_change_not_started: () => m.email_change_not_started()
};
function getApiErrorResponse(e: unknown): ApiErrorResponse | undefined {
@@ -49,6 +51,10 @@ export function getAxiosErrorMessage(
return codeMessage || (typeof response.error === 'string' ? response.error : defaultMessage);
}
export function getAxiosErrorCode(e: unknown): string | undefined {
return getApiErrorResponse(e)?.code;
}
export function getAxiosErrorRequestId(e: unknown): string | undefined {
if (!isAxiosError(e)) {
return undefined;
+13 -1
View File
@@ -1,8 +1,10 @@
import type { AppConfig } from '#lib/types/application-configuration.type.ts';
import type { User } from '#lib/types/user.type.ts';
import { isEmailVerificationPending } from './email-verification-util.ts';
// Returns the path to redirect to based on the current path and user authentication status
// If no redirect is needed, it returns null
export function getAuthRedirectPath(url: URL, user: User | null) {
export function getAuthRedirectPath(url: URL, user: User | null, appConfig: AppConfig | null) {
const path = url.pathname;
const isSignedIn = !!user;
const isAdmin = user?.isAdmin;
@@ -28,6 +30,7 @@ export function getAuthRedirectPath(url: URL, user: User | null) {
].includes(path);
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
const isSettingsPath = path == '/settings' || path.startsWith('/settings/');
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
const redirect = url.pathname + url.search;
@@ -42,5 +45,14 @@ export function getAuthRedirectPath(url: URL, user: User | null) {
return '/settings';
}
// A user who still has to verify their email can only reach their account settings until they do
if (
isSettingsPath &&
path != '/settings/account' &&
isEmailVerificationPending(user, appConfig)
) {
return '/settings/account';
}
return null;
}
+1 -1
View File
@@ -31,7 +31,7 @@ export const load: LayoutLoad = async ({ url }) => {
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
const redirectPath = getAuthRedirectPath(url, user);
const redirectPath = getAuthRedirectPath(url, user, appConfig);
if (redirectPath) {
redirect(302, redirectPath);
}
+14 -3
View File
@@ -14,7 +14,7 @@
import type { DeviceLoginVerificationInfo } from '#lib/types/device-login.type.ts';
import type { OidcDeviceCodeInfo } from '#lib/types/oidc.type.ts';
import { getClientIDHost } from '#lib/utils/client-id-util.ts';
import { getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { getAxiosErrorCode, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { preventDefault } from '#lib/utils/event-util.ts';
import { startAuthentication } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
@@ -44,6 +44,8 @@
);
let codeComplete = $derived(normalizedUserCode.length === 8);
let completed = $derived(success || deviceLoginOutcome !== undefined);
let errorCode: string | undefined = $state();
let emailVerificationRequired = $derived(errorCode === 'email_verification_required');
let deviceLoginLocation = $derived(
deviceLoginInfo?.city && deviceLoginInfo.country
? `${deviceLoginInfo.city}, ${deviceLoginInfo.country}`
@@ -62,6 +64,7 @@
isLoading = true;
errorMessage = null;
errorCode = undefined;
try {
await authenticateUserIfNeeded();
@@ -93,6 +96,7 @@
success = true;
} catch (error) {
errorMessage = getWebauthnErrorMessage(error);
errorCode = getAxiosErrorCode(error);
} finally {
isLoading = false;
}
@@ -111,6 +115,7 @@
isLoading = true;
deviceLoginDecision = decision;
errorMessage = null;
errorCode = undefined;
try {
if (decision === 'approve') {
await reauthenticate();
@@ -119,6 +124,7 @@
deviceLoginOutcome = decision === 'approve' ? 'approved' : 'denied';
} catch (error) {
errorMessage = getWebauthnErrorMessage(error);
errorCode = getAxiosErrorCode(error);
} finally {
isLoading = false;
deviceLoginDecision = undefined;
@@ -137,6 +143,7 @@
function retry() {
errorMessage = null;
errorCode = undefined;
if (!deviceLoginInfo) {
userCode = '';
}
@@ -168,7 +175,7 @@
</h1>
{#if errorMessage}
<p class="text-muted-foreground mt-2">
{errorMessage}. {m.please_try_again()}
{errorMessage}. {#if !emailVerificationRequired}{m.please_try_again()}{/if}
</p>
{:else if deviceLoginOutcome === 'approved'}
<p class="text-muted-foreground mt-2">{m.the_requesting_device_has_been_signed_in()}</p>
@@ -268,7 +275,11 @@
<div class="mt-10 flex w-full max-w-112.5 gap-2">
{#if errorMessage}
<Button class="flex-1" variant="secondary" href="/">{m.cancel()}</Button>
<Button class="flex-1" onclick={retry}>{m.try_again()}</Button>
{#if emailVerificationRequired}
<Button class="flex-1" href="/settings/account">{m.verify_email_address()}</Button>
{:else}
<Button class="flex-1" onclick={retry}>{m.try_again()}</Button>
{/if}
{:else if deviceLoginInfo}
<Button
class="flex-1"
+11 -4
View File
@@ -14,7 +14,7 @@
import type { InteractionStep } from '#lib/types/oidc.type.ts';
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
import { getClientIDHost } from '#lib/utils/client-id-util.ts';
import { getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { getAxiosErrorCode, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { startAuthentication } from '@simplewebauthn/browser';
import { slide } from 'svelte/transition';
import ClientProviderImages from '../authorize/components/client-provider-images.svelte';
@@ -28,6 +28,7 @@
let isLoading = $state(false);
let success = $state(false);
let errorMessage: string | null = $state(null);
let errorCode: string | undefined = $state();
let currentStep = $derived(interactionSession.currentStep);
const fullName = $derived.by(() => {
@@ -53,6 +54,7 @@
async function handlePipeline() {
isLoading = true;
errorMessage = null;
errorCode = undefined;
try {
if (!$userStore) {
await authenticate();
@@ -66,6 +68,7 @@
} catch (e) {
success = false;
errorMessage = getWebauthnErrorMessage(e);
errorCode = getAxiosErrorCode(e);
} finally {
isLoading = false;
}
@@ -196,9 +199,13 @@
</div>
{/if}
<div class="flex w-full max-w-md flex-row-reverse gap-2">
<Button class="flex-1" {isLoading} onclick={handlePipeline} autofocus={true}>
{errorMessage ? m.try_again() : m.sign_in()}
</Button>
{#if errorCode === 'email_verification_required'}
<Button class="flex-1" href="/settings/account">{m.verify_email_address()}</Button>
{:else}
<Button class="flex-1" {isLoading} onclick={handlePipeline} autofocus={true}>
{errorMessage ? m.try_again() : m.sign_in()}
</Button>
{/if}
<Button class="flex-1" variant="secondary" href={document.referrer || '/'}>
{m.cancel()}
</Button>
@@ -2,6 +2,9 @@
import SignInWrapper from '#lib/components/login-wrapper.svelte';
import { Button } from '#lib/components/ui/button/index.ts';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import userStore from '#lib/stores/user-store.ts';
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
import ClientProviderImages from '../../authorize/components/client-provider-images.svelte';
import type { PageProps } from './$types';
@@ -22,7 +25,20 @@
{error}
</p>
<Button class="w-full sm:w-[50%]" variant="secondary" href={document.referrer || '/'}>
{m.go_back()}
</Button>
<div class="flex w-full flex-col items-center gap-3">
{#if isEmailVerificationPending($userStore, $appConfigStore)}
<div class="flex w-full max-w-md flex-row-reverse gap-2">
<Button class="flex-1" href="/settings/account">
{m.verify_email_address()}
</Button>
<Button class="flex-1" variant="secondary" href={document.referrer || '/'}>
{m.go_back()}
</Button>
</div>
{:else}
<Button class="w-full sm:w-[50%]" variant="secondary" href={document.referrer || '/'}>
{m.go_back()}
</Button>
{/if}
</div>
</SignInWrapper>
+9 -5
View File
@@ -7,6 +7,7 @@
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import userStore from '#lib/stores/user-store.ts';
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
import { LucideTriangleAlert } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import { fade, fly } from 'svelte/transition';
@@ -28,11 +29,14 @@
children?: NavItem[];
};
const items: NavItem[] = [
{ href: '/settings/account', label: m.my_account() },
{ href: '/settings/apps', label: m.my_apps() },
{ href: '/settings/audit-log', label: m.audit_log() }
];
// A user who still has to verify their email can only reach their account until they do
const items: NavItem[] = isEmailVerificationPending(user, $appConfigStore)
? [{ href: '/settings/account', label: m.my_account() }]
: [
{ href: '/settings/account', label: m.my_account() },
{ href: '/settings/apps', label: m.my_apps() },
{ href: '/settings/audit-log', label: m.audit_log() }
];
const adminChildren: NavItem[] = [
{ href: '/settings/admin/users', label: m.users() },
@@ -11,8 +11,14 @@
import userStore from '#lib/stores/user-store.ts';
import type { Passkey } from '#lib/types/passkey.type.ts';
import type { AccountUpdate } from '#lib/types/user.type.ts';
import { getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import { KeyRound, Languages, LucideAlertTriangle, UserCog } from '@lucide/svelte';
import { axiosErrorToast, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
import {
KeyRound,
Languages,
LucideAlertTriangle,
LucideMailCheck,
UserCog
} from '@lucide/svelte';
import { startRegistration } from '@simplewebauthn/browser';
import { toast } from 'svelte-sonner';
import AccountForm from './account-form.svelte';
@@ -23,6 +29,7 @@
let { data } = $props();
let account = $state(data.account);
let passkeys = $state(data.passkeys);
let pendingEmailChange = $state(data.pendingEmailChange);
let passkeyToRename: Passkey | null = $state(null);
const userService = new UserService();
const webauthnService = new WebAuthnService();
@@ -32,7 +39,25 @@
);
async function updateAccount(user: AccountUpdate) {
userStore.setUser(await userService.updateCurrent(user));
const updated = await userService.updateCurrent(user);
userStore.setUser(updated);
// A verified email is only replaced once the new address is confirmed, so the change shows up as pending instead
if (user.email && user.email !== updated.email) {
pendingEmailChange = await userService.getPendingEmailChange().catch(() => null);
}
return updated;
}
async function cancelEmailChange() {
await userService
.cancelEmailChange()
.then(() => {
pendingEmailChange = null;
toast.success(m.email_change_canceled());
})
.catch(axiosErrorToast);
}
async function createPasskey() {
@@ -55,7 +80,7 @@
{#if passkeys.length == 0}
<Alert.Root variant="warning" class="flex gap-3">
<LucideAlertTriangle class="size-4" />
<LucideAlertTriangle class="size-4 shrink-0" />
<div class="md:flex md:w-full md:place-content-between">
<div>
<Alert.Title class="font-semibold">{m.passkey_missing()}</Alert.Title>
@@ -72,7 +97,7 @@
</Alert.Root>
{:else if passkeys.length == 1}
<Alert.Root variant="warning" dismissibleId="single-passkey" class="flex gap-3">
<LucideAlertTriangle class="size-4" />
<LucideAlertTriangle class="size-4 shrink-0" />
<div>
<Alert.Title class="font-semibold">{m.single_passkey_configured()}</Alert.Title>
<Alert.Description class="text-sm">
@@ -82,6 +107,30 @@
</Alert.Root>
{/if}
{#if pendingEmailChange}
<Alert.Root variant="info" class="flex gap-3">
<LucideMailCheck class="size-4 shrink-0" />
<div class="md:flex md:w-full md:place-content-between">
<div>
<Alert.Title class="font-semibold">{m.email_change_pending_title()}</Alert.Title>
<Alert.Description class="text-sm">
{m.email_change_pending_description({ email: pendingEmailChange.email })}
</Alert.Description>
</div>
<div>
<Button
class="mt-2 md:mt-0"
variant="outline"
usePromiseLoading
onclick={cancelEmailChange}
>
{m.cancel_email_change()}
</Button>
</div>
</div>
</Alert.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title>
@@ -3,9 +3,13 @@ import type { PageLoad } from './$types';
export const load: PageLoad = async () => {
const userService = new UserService();
const account = await userService.getCurrent();
const [account, pendingEmailChange] = await Promise.all([
userService.getCurrent(),
userService.getPendingEmailChange().catch(() => null)
]);
return {
account
account,
pendingEmailChange
};
};
@@ -5,7 +5,7 @@
import { m } from '#lib/paraglide/messages.js';
import UserService from '#lib/services/user-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type { AccountUpdate } from '#lib/types/user.type.ts';
import type { AccountUpdate, User } from '#lib/types/user.type.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { createForm } from '#lib/utils/form-util.ts';
import { trackFormChanges } from '#lib/utils/unsaved-changes-util.svelte.ts';
@@ -23,7 +23,7 @@
}: {
account: AccountUpdate;
userId: string;
callback: (user: AccountUpdate) => Promise<void>;
callback: (user: AccountUpdate) => Promise<User>;
isLdapUser?: boolean;
userInfoInputDisabled?: boolean;
} = $props();
@@ -55,7 +55,19 @@
}
}
trackFormChanges(() => formStore, callback);
trackFormChanges(
() => formStore,
async (data) => {
const saved = await callback(data);
// A verified email is only replaced once the new address is confirmed, so keep showing the email that is still in effect
// The saved data becomes the new baseline, so it has to carry that email as well
if (saved.email && data.email && saved.email !== data.email) {
data.email = saved.email;
formStore.setValue('email', saved.email);
}
}
);
async function updateProfilePicture(image: File) {
await userService
@@ -46,6 +46,18 @@
}
});
const emailVerificationOptions = $derived({
disabled: { label: m.disabled(), description: m.email_verification_disabled_description() },
optional: {
label: m.email_verification_optional(),
description: m.email_verification_optional_description()
},
required: {
label: m.email_verification_required(),
description: m.email_verification_required_description()
}
});
let isSendingTestEmail = $state(false);
const formSchema = z
@@ -63,7 +75,7 @@
smtpTls: z.enum(['none', 'starttls', 'tls']),
smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(),
emailVerificationEnabled: z.boolean(),
emailVerification: z.enum(['disabled', 'optional', 'required']),
emailOneTimeAccessAsAdminEnabled: z.boolean(),
emailLoginNotificationMode: z.enum([
'disabled',
@@ -82,7 +94,6 @@
const emailFields: (keyof z.infer<typeof formSchema>)[] = [
'emailOneTimeAccessAsUnauthenticatedEnabled',
'emailVerificationEnabled',
'emailOneTimeAccessAsAdminEnabled',
'emailApiKeyExpirationEnabled'
];
@@ -105,8 +116,19 @@
requireFieldsWhen(anyProvided, m.smtp_field_required_when_other_provided());
const emailEnabled =
data.emailLoginNotificationMode !== 'disabled' || emailFields.some((f) => data[f]);
data.emailLoginNotificationMode !== 'disabled' ||
data.emailVerification !== 'disabled' ||
emailFields.some((f) => data[f]);
requireFieldsWhen(emailEnabled, m.smtp_field_required_when_email_enabled());
// Requiring verification only protects accounts that have an email
if (data.emailVerification === 'required' && !data.requireUserEmail) {
ctx.addIssue({
code: 'custom',
path: ['emailVerification'],
message: m.email_verification_required_needs_user_email()
});
}
});
const formStore = createForm(formSchema, pickSchemaValues(formSchema, appConfig));
@@ -175,10 +197,53 @@
description={m.require_user_email_description()}
bind:checked={$inputs.requireUserEmail.value}
/>
<Field.Field>
<div>
<Field.Label for="email-verification">{m.email_verification()}</Field.Label>
<Field.Description>{m.email_verification_description()}</Field.Description>
</div>
<Select.Root
type="single"
disabled={$appConfigStore.uiConfigDisabled}
value={$inputs.emailVerification.value}
allowDeselect={false}
onValueChange={(value) => {
$inputs.emailVerification.value = value as AllAppConfig['emailVerification'];
// Emails verified by default would let every new account skip the verification
if (value === 'required') {
$inputs.emailsVerified.value = false;
}
}}
>
<Select.Trigger
id="email-verification"
class="w-full"
aria-invalid={!!$inputs.emailVerification.error}
>
{emailVerificationOptions[$inputs.emailVerification.value].label}
</Select.Trigger>
<Select.Content class="w-[calc(var(--bits-select-anchor-width)+--spacing(3))]">
<Select.Group>
{#each Object.entries(emailVerificationOptions) as [value, option] (value)}
<Select.Item {value} label={option.label}>
<div class="flex flex-col items-start gap-1 whitespace-normal">
<span class="font-medium">{option.label}</span>
<span class="text-muted-foreground text-xs">{option.description}</span>
</div>
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
{#if $inputs.emailVerification.error}
<Field.Error>{$inputs.emailVerification.error}</Field.Error>
{/if}
</Field.Field>
<SwitchWithLabel
id="emails-verified-by-default"
label={m.emails_verified_by_default()}
description={m.emails_verified_by_default_description()}
disabled={$inputs.emailVerification.value === 'required'}
bind:checked={$inputs.emailsVerified.value}
/>
</div>
@@ -246,12 +311,6 @@
</Select.Content>
</Select.Root>
</Field.Field>
<SwitchWithLabel
id="email-verification"
label={m.email_verification()}
description={m.email_verification_description()}
bind:checked={$inputs.emailVerificationEnabled.value}
/>
<SwitchWithLabel
id="email-login-admin"
label={m.email_login_code_from_admin()}