feat: self-service user signup (#672)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-06-27 15:01:10 -05:00
committed by GitHub
parent 1fdb058386
commit dcd1ae96e0
49 changed files with 7366 additions and 5729 deletions

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import SignupForm from '$lib/components/signup/signup-form.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { UserSignUp } from '$lib/types/user.type';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { tryCatch } from '$lib/utils/try-catch-util';
import { LucideChevronLeft } from '@lucide/svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../login/components/login-logo-error-success-indicator.svelte';
let { data } = $props();
const userService = new UserService();
let isLoading = $state(false);
let error: string | undefined = $state();
async function handleSignup(userData: UserSignUp) {
isLoading = true;
const result = await tryCatch(userService.signup({ ...userData, token: data.token }));
if (result.error) {
error = getAxiosErrorMessage(result.error);
isLoading = false;
return false;
}
userStore.setUser(result.data);
isLoading = false;
goto('/signup/add-passkey');
return true;
}
onMount(() => {
if (!$appConfigStore.allowUserSignups || $appConfigStore.allowUserSignups === 'disabled') {
error = m.user_signups_are_disabled();
return;
}
// For token-based signups, check if we have a valid token
if ($appConfigStore.allowUserSignups === 'withToken' && !data.token) {
error = m.signup_requires_valid_token();
}
});
</script>
<svelte:head>
<title>{m.signup()}</title>
</svelte:head>
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
{m.signup_to_appname({ appName: $appConfigStore.appName })}
</h1>
{#if !error}
<p class="text-muted-foreground mt-2" in:fade>
{m.create_your_account_to_get_started()}
</p>
{:else}
<p class="text-muted-foreground mt-2" in:fade>
{error}.
</p>
{/if}
{#if $appConfigStore.allowUserSignups === 'open' || data.token}
<SignupForm callback={handleSignup} {isLoading} />
<div class="mt-10 flex w-full items-center justify-between gap-2">
<a class="text-muted-foreground mt-5 flex text-sm" href="/login"
><LucideChevronLeft class="size-5" /> {m.back()}</a
>
<Button type="submit" form="sign-up-form" onclick={() => (error = undefined)}
>{m.signup()}</Button
>
</div>
{:else}
<Button class="mt-10" href="/login">{m.go_to_login()}</Button>
{/if}
</SignInWrapper>

View File

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

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { tryCatch } from '$lib/utils/try-catch-util';
import { startRegistration } from '@simplewebauthn/browser';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../../login/components/login-logo-error-success-indicator.svelte';
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
let error: string | undefined = $state();
async function createPasskeyAndContinue() {
isLoading = true;
error = undefined;
const optsResult = await tryCatch(webauthnService.getRegistrationOptions());
if (optsResult.error) {
error = getWebauthnErrorMessage(optsResult.error);
isLoading = false;
return;
}
const attRespResult = await tryCatch(startRegistration({ optionsJSON: optsResult.data }));
if (attRespResult.error) {
error = getWebauthnErrorMessage(attRespResult.error);
isLoading = false;
return;
}
const finishResult = await tryCatch(webauthnService.finishRegistration(attRespResult.data));
if (finishResult.error) {
error = getWebauthnErrorMessage(finishResult.error);
isLoading = false;
return;
}
goto('/settings/account');
isLoading = false;
}
</script>
<svelte:head>
<title>{m.add_passkey()}</title>
</svelte:head>
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
<div class="w-full text-center">
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
{m.setup_your_passkey()}
</h1>
<p class="text-muted-foreground mt-2" in:fade>
{#if !error}
{m.create_a_passkey_to_securely_access_your_account()}
{:else}
{error}. {m.please_try_again()}
{/if}
</p>
<div class="mt-10 flex w-full justify-between gap-2">
<Button
variant="secondary"
onclick={() => goto('/settings/account')}
disabled={isLoading}
class="flex-1"
>
{m.skip_for_now()}
</Button>
<Button onclick={createPasskeyAndContinue} {isLoading} class="flex-1">
{m.add_passkey()}
</Button>
</div>
</div>
</SignInWrapper>