feat: improve initial admin creation workflow

This commit is contained in:
Elias Schneider
2025-06-27 23:38:02 +02:00
parent 73e7e0b1c5
commit 287314f016
20 changed files with 180 additions and 108 deletions

View File

@@ -67,9 +67,7 @@
"please_try_to_sign_in_again": "Please try to sign in again.",
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
"authenticate": "Authenticate",
"appname_setup": "{appName} Setup",
"please_try_again": "Please try again.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "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.",
"continue": "Continue",
"alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
@@ -391,6 +389,7 @@
"go_to_login": "Go to login",
"signup_to_appname": "Sign Up to {appName}",
"create_your_account_to_get_started": "Create your account to get started.",
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
"setup_your_passkey": "Set up your passkey",
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
"skip_for_now": "Skip for now",
@@ -417,5 +416,7 @@
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
"signup_open": "Open Signup",
"signup_open_description": "Anyone can create a new account without restrictions.",
"of": "of"
"of": "of",
"skip_passkey_setup": "Skip Passkey Setup",
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
}

View File

@@ -114,6 +114,11 @@ export default class UserService extends APIService {
return res.data as User;
}
async signupInitialUser(data: UserSignUp) {
const res = await this.api.post(`/signup/setup`, data);
return res.data as User;
}
async listSignupTokens(options?: SearchPaginationSortRequest) {
const res = await this.api.get('/signup-tokens', {
params: options

View File

@@ -12,7 +12,7 @@ export function getAuthRedirectPath(path: string, user: User | null) {
path == '/lc' ||
path.startsWith('/lc/') ||
path == '/signup' ||
path.startsWith('/signup/') ||
path == '/signup/setup' ||
path.startsWith('/st/');
const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path);
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');

View File

@@ -1,49 +0,0 @@
<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 UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import LoginLogoErrorSuccessIndicator from '../components/login-logo-error-success-indicator.svelte';
let isLoading = $state(false);
let error: string | undefined = $state();
const userService = new UserService();
async function authenticate() {
isLoading = true;
try {
const user = await userService.exchangeOneTimeAccessToken('setup');
userStore.setUser(user);
goto('/settings');
} catch (e) {
error = getAxiosErrorMessage(e);
}
isLoading = false;
}
</script>
<SignInWrapper animate={!$appConfigStore.disableAnimations}>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">
{m.appname_setup({ appName: $appConfigStore.appName })}
</h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. {m.please_try_again()}
</p>
{:else}
<p class="text-muted-foreground mt-2">
{m.you_are_about_to_sign_in_to_the_initial_admin_account()}
</p>
<Button class="mt-5" {isLoading} onclick={authenticate}>{m.continue()}</Button>
{/if}
</SignInWrapper>

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
// Alias for /signup/setup
export const load: PageLoad = async () => redirect(307, '/signup/setup');

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
@@ -44,6 +45,20 @@
goto('/settings/account');
isLoading = false;
}
function skipForNow() {
openConfirmDialog({
title: m.skip_passkey_setup(),
message: m.skip_passkey_setup_description(),
confirm: {
label: m.skip_for_now(),
destructive: true,
action: () => {
goto('/settings/account');
}
}
});
}
</script>
<svelte:head>
@@ -66,12 +81,7 @@
{/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"
>
<Button variant="secondary" onclick={skipForNow} disabled={isLoading} class="flex-1">
{m.skip_for_now()}
</Button>
<Button onclick={createPasskeyAndContinue} {isLoading} class="flex-1">

View File

@@ -0,0 +1,70 @@
<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 { 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.signupInitialUser(userData));
if (result.error) {
error = getAxiosErrorMessage(result.error);
isLoading = false;
return false;
}
userStore.setUser(result.data);
isLoading = false;
goto('/signup/add-passkey');
return true;
}
</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.initial_account_creation_description()}
</p>
{:else}
<p class="text-muted-foreground mt-2" in:fade>
{error}.
</p>
{/if}
<SignupForm callback={handleSignup} {isLoading} />
<div class="mt-10 flex w-full justify-end">
<Button type="submit" form="sign-up-form" onclick={() => (error = undefined)}
>{m.signup()}</Button
>
</div>
</SignInWrapper>