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
+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()}