feat: allow sign in with email (#100)

This commit is contained in:
Elias Schneider
2025-01-19 15:30:31 +01:00
committed by GitHub
parent e284e352e2
commit 06b90eddd6
42 changed files with 422 additions and 145 deletions

View File

@@ -91,7 +91,7 @@
{#if client == null}
<p>Client not found</p>
{:else}
<SignInWrapper>
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if errorMessage}
@@ -136,7 +136,7 @@
</Card.Root>
</div>
{/if}
<div class="flex justify-center gap-2">
<div class="flex w-full justify-stretch gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button>
{#if !errorMessage}
<Button

View File

@@ -8,7 +8,7 @@
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { fade } from 'svelte/transition';
import LoginLogoErrorIndicator from './components/login-logo-error-indicator.svelte';
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
@@ -35,9 +35,9 @@
<title>Sign In</title>
</svelte:head>
<SignInWrapper>
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
<div class="flex justify-center">
<LoginLogoErrorIndicator error={!!error} />
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName}

View File

@@ -1,7 +1,8 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
export const load: PageServerLoad = async ({ params, url }) => {
return {
token: params.token
token: params.token,
redirect: url.searchParams.get('redirect') || '/settings'
};
};

View File

@@ -1,51 +1,69 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js';
import type { User } from '$lib/types/user.type.js';
import { axiosErrorToast } from '$lib/utils/error-util';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
let { data } = $props();
let isLoading = $state(false);
let error: string | undefined = $state();
const skipPage = data.redirect !== '/settings';
const userService = new UserService();
async function authenticate() {
isLoading = true;
userService
.exchangeOneTimeAccessToken(data.token)
.then((user: User) => {
userStore.setUser(user);
goto('/settings');
})
.catch(axiosErrorToast);
try {
const user = await userService.exchangeOneTimeAccessToken(data.token);
userStore.setUser(user);
try {
goto(data.redirect);
} catch (e) {
error = 'Invalid redirect URL';
}
} catch (e) {
error = getAxiosErrorMessage(e);
}
isLoading = false;
}
onMount(() => {
if (skipPage) {
authenticate();
}
});
</script>
<SignInWrapper>
<div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">
{data.token === 'setup' ? `${$appConfigStore.appName} Setup` : 'One Time Access'}
</h1>
<p class="text-muted-foreground mt-2">
{#if data.token === 'setup'}
You're about to sign in to the initial admin account. Anyone with this link can access the
account until a passkey is added. Please set up a passkey as soon as possible to prevent
unauthorized access.
{:else}
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
you'll need to request a new link.
{/if}
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. Please try again.
</p>
{:else if !skipPage}
<p class="text-muted-foreground mt-2">
{#if data.token === 'setup'}
You're about to sign in to the initial admin account. Anyone with this link can access the
account until a passkey is added. Please set up a passkey as soon as possible to prevent
unauthorized access.
{:else}
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that
if you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
you'll need to request a new link.
{/if}
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
{/if}
</SignInWrapper>

View File

@@ -1,22 +1,29 @@
<script lang="ts">
import Logo from '$lib/components/logo.svelte';
import CheckmarkAnimated from '$lib/icons/checkmark-animated.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte';
import { fade } from 'svelte/transition';
const {
error
error,
success
}: {
error: boolean;
error?: boolean;
success?: boolean;
} = $props();
</script>
<div
class="rounded-2xl p-3 transition-[background-color] duration-300
{error ? 'bg-red-200' : 'bg-muted'}"
{error ? 'bg-red-200' : success ? 'bg-green-200' : 'bg-muted'}"
>
{#if error}
{#if error || success}
<div class="flex h-10 w-10 items-center justify-center">
<CrossAnimated class="h-5 w-5" />
{#if error}
<CrossAnimated class="h-5 w-5" />
{:else}
<CheckmarkAnimated class="h-5 w-5" />
{/if}
</div>
{:else}
<div in:fade={{ duration: 300 }}>

View File

@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
return {
redirect: url.searchParams.get('redirect') || undefined
};
};

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button';
import Input from '$lib/components/ui/input/input.svelte';
import UserService from '$lib/services/user-service';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
const { data } = $props();
const userService = new UserService();
let email = $state('');
let isLoading = $state(false);
let error: string | undefined = $state(undefined);
let success = $state(false);
async function requestEmail() {
isLoading = true;
await userService
.requestOneTimeAccessEmail(email, data.redirect)
.then(() => (success = true))
.catch((e) => (error = e.response?.data.error || 'An unknown error occured'));
isLoading = false;
}
</script>
<svelte:head>
<title>Email One Time Access</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Email One Time Access</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try again.
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button>
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button>
</div>
{:else if success}
<p class="text-muted-foreground mt-2" in:fade>
An email has been sent to the provided email, if it exists in the system.
</p>
{:else}
<form onsubmit={requestEmail}>
<p class="text-muted-foreground mt-2" in:fade>
Enter your email to receive an email with a one time access link.
</p>
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} />
<div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button>
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
</div>
</form>
{/if}
</SignInWrapper>

View File

@@ -16,7 +16,7 @@
const appConfigService = new AppConfigService();
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
await appConfigService
appConfig = await appConfigService
.update({
...appConfig,
...updatedAppConfig

View File

@@ -19,17 +19,17 @@
const appConfigService = new AppConfigService();
let isSendingTestEmail = $state(false);
let emailEnabled = $state(appConfig.emailEnabled);
const updatedAppConfig = {
emailEnabled: appConfig.emailEnabled,
smtpHost: appConfig.smtpHost,
smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom,
smtpTls: appConfig.smtpTls,
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
smtpSkipCertVerify: appConfig.smtpSkipCertVerify,
emailOneTimeAccessEnabled: appConfig.emailOneTimeAccessEnabled,
emailLoginNotificationEnabled: appConfig.emailLoginNotificationEnabled
};
const formSchema = z.object({
@@ -39,7 +39,9 @@
smtpPassword: z.string(),
smtpFrom: z.string().email(),
smtpTls: z.boolean(),
smtpSkipCertVerify: z.boolean()
smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessEnabled: z.boolean(),
emailLoginNotificationEnabled: z.boolean()
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -47,26 +49,11 @@
async function onSubmit() {
const data = form.validate();
if (!data) return false;
await callback({
...data,
emailEnabled: true
});
await callback(data);
toast.success('Email configuration updated successfully');
return true;
}
async function onDisable() {
emailEnabled = false;
await callback({ emailEnabled });
toast.success('Email disabled successfully');
}
async function onEnable() {
if (await onSubmit()) {
emailEnabled = true;
}
}
async function onTestEmail() {
isSendingTestEmail = true;
await appConfigService
@@ -80,7 +67,8 @@
</script>
<form onsubmit={onSubmit}>
<div class="mt-5 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<h4 class="text-lg font-semibold">SMTP Configuration</h4>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
@@ -99,15 +87,26 @@
bind:checked={$inputs.smtpSkipCertVerify.value}
/>
</div>
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
<div class="mt-4 flex flex-col gap-3">
<CheckboxWithLabel
id="email-login-notification"
label="Email Login Notification"
description="Send an email to the user when they log in from a new device."
bind:checked={$inputs.emailLoginNotificationEnabled.value}
/>
<CheckboxWithLabel
id="email-one-time-access"
label="Email One Time Access"
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/>
</div>
<div class="mt-8 flex flex-wrap justify-end gap-3">
{#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send Test Email</Button
>
<Button type="submit">Save</Button>
{:else}
<Button onclick={onEnable}>Enable</Button>
{/if}
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send Test Email</Button
>
<Button type="submit">Save</Button>
</div>
</form>

View File

@@ -2,8 +2,6 @@
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
@@ -23,14 +21,14 @@
appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration,
emailsVerified: appConfig.emailsVerified,
allowOwnAccountEdit: appConfig.allowOwnAccountEdit
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
};
const formSchema = z.object({
appName: z.string().min(2).max(30),
sessionDuration: z.number().min(1).max(43200),
emailsVerified: z.boolean(),
allowOwnAccountEdit: z.boolean()
allowOwnAccountEdit: z.boolean(),
});
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
@@ -63,7 +61,7 @@
label="Emails Verified"
description="Whether the user's email should be marked as verified for the OIDC clients."
bind:checked={$inputs.emailsVerified.value}
/>
/>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>

View File

@@ -88,7 +88,6 @@
label="Public Client"
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
onCheckedChange={(v) => {
console.log(v)
if (v == true) form.setValue('pkceEnabled', true);
}}
bind:checked={$inputs.isPublic.value}