mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-12 19:05:14 +00:00
feat: self-service user signup (#672)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
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 userStore from '$lib/stores/user-store';
|
||||
@@ -9,7 +10,6 @@
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
let isLoading = $state(false);
|
||||
@@ -49,10 +49,17 @@
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{m.authenticate_yourself_with_your_passkey_to_access_the_admin_panel()}
|
||||
{m.authenticate_with_passkey_to_access_account()}
|
||||
</p>
|
||||
{/if}
|
||||
<Button class="mt-10" {isLoading} onclick={authenticate} autofocus={true}>
|
||||
{error ? m.try_again() : m.authenticate()}
|
||||
</Button>
|
||||
<div class="mt-10 flex justify-center gap-3">
|
||||
{#if $appConfigStore.allowUserSignups === 'open'}
|
||||
<Button variant="secondary" href="/signup">
|
||||
{m.signup()}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button {isLoading} onclick={authenticate} autofocus={true}>
|
||||
{error ? m.try_again() : m.authenticate()}
|
||||
</Button>
|
||||
</div>
|
||||
</SignInWrapper>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
@@ -22,11 +23,27 @@
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
const signupOptions = {
|
||||
disabled: {
|
||||
label: m.disabled(),
|
||||
description: m.signup_disabled_description()
|
||||
},
|
||||
withToken: {
|
||||
label: m.signup_with_token(),
|
||||
description: m.signup_with_token_description()
|
||||
},
|
||||
open: {
|
||||
label: m.signup_open(),
|
||||
description: m.signup_open_description()
|
||||
}
|
||||
};
|
||||
|
||||
const updatedAppConfig = {
|
||||
appName: appConfig.appName,
|
||||
sessionDuration: appConfig.sessionDuration,
|
||||
emailsVerified: appConfig.emailsVerified,
|
||||
allowOwnAccountEdit: appConfig.allowOwnAccountEdit,
|
||||
allowUserSignups: appConfig.allowUserSignups,
|
||||
disableAnimations: appConfig.disableAnimations,
|
||||
accentColor: appConfig.accentColor
|
||||
};
|
||||
@@ -36,6 +53,7 @@
|
||||
sessionDuration: z.number().min(1).max(43200),
|
||||
emailsVerified: z.boolean(),
|
||||
allowOwnAccountEdit: z.boolean(),
|
||||
allowUserSignups: z.enum(['disabled', 'withToken', 'open']),
|
||||
disableAnimations: z.boolean(),
|
||||
accentColor: z.string()
|
||||
});
|
||||
@@ -62,13 +80,60 @@
|
||||
description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
|
||||
bind:input={$inputs.sessionDuration}
|
||||
/>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label class="mb-0" for="enable-user-signup">{m.enable_user_signups()}</Label>
|
||||
<p class="text-muted-foreground text-[0.8rem]">
|
||||
{m.enable_user_signups_description()}
|
||||
</p>
|
||||
<Select.Root
|
||||
disabled={$appConfigStore.uiConfigDisabled}
|
||||
type="single"
|
||||
value={$inputs.allowUserSignups.value}
|
||||
onValueChange={(v) =>
|
||||
($inputs.allowUserSignups.value = v as typeof $inputs.allowUserSignups.value)}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="w-full"
|
||||
aria-label={m.enable_user_signups()}
|
||||
placeholder={m.enable_user_signups()}
|
||||
>
|
||||
{signupOptions[$inputs.allowUserSignups.value]?.label}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="disabled">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-medium">{signupOptions.disabled.label}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{signupOptions.disabled.description}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="withToken">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-medium">{signupOptions.withToken.label}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{signupOptions.withToken.description}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="open">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span class="font-medium">{signupOptions.open.label}</span>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{signupOptions.open.description}
|
||||
</span>
|
||||
</div>
|
||||
</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<SwitchWithLabel
|
||||
id="self-account-editing"
|
||||
label={m.enable_self_account_editing()}
|
||||
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
|
||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||
/>
|
||||
|
||||
<SwitchWithLabel
|
||||
id="emails-verified"
|
||||
label={m.emails_verified()}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import SignupTokenListModal from '$lib/components/signup/signup-token-list-modal.svelte';
|
||||
import SignupTokenModal from '$lib/components/signup/signup-token-modal.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as DropdownButton from '$lib/components/ui/dropdown-button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
@@ -15,8 +18,13 @@
|
||||
let { data } = $props();
|
||||
let users = $state(data.users);
|
||||
let usersRequestOptions = $state(data.usersRequestOptions);
|
||||
let signupTokens = $state(data.signupTokens);
|
||||
let signupTokensRequestOptions = $state(data.signupTokensRequestOptions);
|
||||
|
||||
let selectedCreateOptions = $state('Add User');
|
||||
let expandAddUser = $state(false);
|
||||
let signupTokenModalOpen = $state(false);
|
||||
let signupTokenListModalOpen = $state(false);
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
@@ -33,6 +41,10 @@
|
||||
users = await userService.list(usersRequestOptions);
|
||||
return success;
|
||||
}
|
||||
|
||||
async function refreshSignupTokens() {
|
||||
signupTokens = await userService.listSignupTokens(signupTokensRequestOptions);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -55,7 +67,30 @@
|
||||
>
|
||||
</div>
|
||||
{#if !expandAddUser}
|
||||
<Button onclick={() => (expandAddUser = true)}>{m.add_user()}</Button>
|
||||
{#if $appConfigStore.allowUserSignups !== 'disabled'}
|
||||
<DropdownButton.DropdownRoot>
|
||||
<DropdownButton.Root>
|
||||
<DropdownButton.Main disabled={false} onclick={() => (expandAddUser = true)}>
|
||||
{selectedCreateOptions}
|
||||
</DropdownButton.Main>
|
||||
|
||||
<DropdownButton.DropdownTrigger>
|
||||
<DropdownButton.Trigger class="border-l" />
|
||||
</DropdownButton.DropdownTrigger>
|
||||
</DropdownButton.Root>
|
||||
|
||||
<DropdownButton.Content align="end">
|
||||
<DropdownButton.Item onclick={() => (signupTokenModalOpen = true)}>
|
||||
{m.create_signup_token()}
|
||||
</DropdownButton.Item>
|
||||
<DropdownButton.Item onclick={() => (signupTokenListModalOpen = true)}>
|
||||
{m.view_active_signup_tokens()}
|
||||
</DropdownButton.Item>
|
||||
</DropdownButton.Content>
|
||||
</DropdownButton.DropdownRoot>
|
||||
{:else}
|
||||
<Button onclick={() => (expandAddUser = true)}>{m.add_user()}</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button class="h-8 p-3" variant="ghost" onclick={() => (expandAddUser = false)}>
|
||||
<LucideMinus class="size-5" />
|
||||
@@ -86,3 +121,11 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<SignupTokenModal bind:open={signupTokenModalOpen} onTokenCreated={refreshSignupTokens} />
|
||||
<SignupTokenListModal
|
||||
bind:open={signupTokenListModalOpen}
|
||||
bind:signupTokens
|
||||
{signupTokensRequestOptions}
|
||||
onTokenDeleted={refreshSignupTokens}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,22 @@ export const load: PageLoad = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const users = await userService.list(usersRequestOptions);
|
||||
return { users, usersRequestOptions };
|
||||
const signupTokensRequestOptions: SearchPaginationSortRequest = {
|
||||
sort: {
|
||||
column: 'createdAt',
|
||||
direction: 'desc'
|
||||
}
|
||||
};
|
||||
|
||||
const [users, signupTokens] = await Promise.all([
|
||||
userService.list(usersRequestOptions),
|
||||
userService.listSignupTokens(signupTokensRequestOptions)
|
||||
]);
|
||||
|
||||
return {
|
||||
users,
|
||||
usersRequestOptions,
|
||||
signupTokens,
|
||||
signupTokensRequestOptions
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
SIGN_IN: m.sign_in(),
|
||||
TOKEN_SIGN_IN: m.token_sign_in(),
|
||||
CLIENT_AUTHORIZATION: m.client_authorization(),
|
||||
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization()
|
||||
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization(),
|
||||
ACCOUNT_CREATED: m.account_created()
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
||||
90
frontend/src/routes/signup/+page.svelte
Normal file
90
frontend/src/routes/signup/+page.svelte
Normal 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>
|
||||
7
frontend/src/routes/signup/+page.ts
Normal file
7
frontend/src/routes/signup/+page.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
return {
|
||||
token: url.searchParams.get('token') || undefined
|
||||
};
|
||||
};
|
||||
82
frontend/src/routes/signup/add-passkey/+page.svelte
Normal file
82
frontend/src/routes/signup/add-passkey/+page.svelte
Normal 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>
|
||||
16
frontend/src/routes/st/[token]/+page.ts
Normal file
16
frontend/src/routes/st/[token]/+page.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
// Alias for /signup?token=...
|
||||
export const load: PageLoad = async ({ url, params }) => {
|
||||
const targetPath = '/signup';
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', params.token);
|
||||
|
||||
if (url.searchParams.has('redirect')) {
|
||||
searchParams.set('redirect', url.searchParams.get('redirect')!);
|
||||
}
|
||||
|
||||
return redirect(307, `${targetPath}?${searchParams.toString()}`);
|
||||
};
|
||||
Reference in New Issue
Block a user