mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-15 06:09:07 +02:00
feat: add ability to force email verification (#1837)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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