mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-30 04:29:05 +01:00
feat: add ability to force email verification (#1837)
This commit is contained in:
@@ -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
|
||||
|
||||
+68
-9
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user