feat(web): improved user onboarding (#18782)

* wip

* added user metadata key

* wip

* restructure onboarding system and add initial locale

* update language card and fix translation updating

* remove prints

* new card formattings

* fix cursed unmount effect

* add OAuth route onboarding

* remove required admin auth for onboarding

* delete the hotwire button

* update open-api files

* delete import

* fix failing oauth onboarding fields

* fix e2e test

* fix web e2e test

* add onboarding to user registration e2e test

* remove todo

this was a holdover during dev and didn't get deleted

* fix server small tests

* use onDestroy to save settings rather than a bind:this

* change to false for isOnboarded

* fix other auth small test

* provide type annotation in user factory metadata field

* remove onboardingCompelted from UserDto

* move translations to onboarding steps array and mark as derived so they update

* break language selector out into its own component as per @danieldietzler suggestion

* remove hello header on card

* fix flixkering on server privacy card

* label/id fixes

* openapi

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Brandon Wees
2025-06-02 16:09:13 -05:00
committed by GitHub
parent e7d7886f44
commit 74438f5bd8
36 changed files with 961 additions and 235 deletions
+15 -1
View File
@@ -26,7 +26,6 @@
let oauthLoading = $state(true);
const onSuccess = async (user: LoginResponseDto) => {
console.log(data.continueUrl);
await goto(data.continueUrl, { invalidateAll: true });
eventManager.emit('auth.login', user);
};
@@ -43,6 +42,12 @@
if (oauth.isCallback(globalThis.location)) {
try {
const user = await oauth.login(globalThis.location);
if (!user.isOnboarded) {
await onOnboarding();
return;
}
await onSuccess(user);
return;
} catch (error) {
@@ -79,10 +84,19 @@
return;
}
// change the user password before we onboard them
if (!user.isAdmin && user.shouldChangePassword) {
await onFirstLogin();
return;
}
// We want to onboard after the first login since their password will change
// and handleLogin will be called again (relogin). We then do onboarding on that next call.
if (!user.isOnboarded) {
await onOnboarding();
return;
}
await onSuccess(user);
return;
} catch (error) {
+115 -27
View File
@@ -1,17 +1,21 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte';
import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
import OnboardingPrivacy from '$lib/components/onboarding-page/onboarding-privacy.svelte';
import OnboardingLocale from '$lib/components/onboarding-page/onboarding-language.svelte';
import OnboardingServerPrivacy from '$lib/components/onboarding-page/onboarding-server-privacy.svelte';
import OnboardingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
import OnboardingUserPrivacy from '$lib/components/onboarding-page/onboarding-user-privacy.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { retrieveServerConfig } from '$lib/stores/server-config.store';
import { updateAdminOnboarding } from '@immich/sdk';
let index = $state(0);
import { OnboardingRole } from '$lib/models/onboarding-role';
import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk';
import { mdiHarddisk, mdiIncognito, mdiThemeLightDark, mdiTranslate } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface OnboardingStep {
name: string;
@@ -19,41 +23,116 @@
| typeof OnboardingHello
| typeof OnboardingTheme
| typeof OnboardingStorageTemplate
| typeof OnboardingPrivacy;
| typeof OnboardingServerPrivacy
| typeof OnboardingUserPrivacy
| typeof OnboardingLocale;
role: OnboardingRole;
title?: string;
icon?: string;
}
const onboardingSteps: OnboardingStep[] = [
{ name: 'hello', component: OnboardingHello },
{ name: 'theme', component: OnboardingTheme },
{ name: 'privacy', component: OnboardingPrivacy },
{ name: 'storage', component: OnboardingStorageTemplate },
];
const onboardingSteps: OnboardingStep[] = $derived([
{ name: 'hello', component: OnboardingHello, role: OnboardingRole.USER },
{
name: 'theme',
component: OnboardingTheme,
role: OnboardingRole.USER,
title: $t('theme'),
icon: mdiThemeLightDark,
},
{
name: 'language',
component: OnboardingLocale,
role: OnboardingRole.USER,
title: $t('language'),
icon: mdiTranslate,
},
{
name: 'server_privacy',
component: OnboardingServerPrivacy,
role: OnboardingRole.SERVER,
title: $t('server_privacy'),
icon: mdiIncognito,
},
{
name: 'user_privacy',
component: OnboardingUserPrivacy,
role: OnboardingRole.USER,
title: $t('user_privacy'),
icon: mdiIncognito,
},
{
name: 'storage_template',
component: OnboardingStorageTemplate,
role: OnboardingRole.SERVER,
title: $t('admin.storage_template_settings'),
icon: mdiHarddisk,
},
]);
run(() => {
let index = $state(0);
let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER);
let onboardingStepCount = $derived(onboardingSteps.filter((step) => shouldRunStep(step.role, userRole)).length);
let onboardingProgress = $derived(
onboardingSteps.filter((step, i) => shouldRunStep(step.role, userRole) && i <= index).length - 1,
);
const shouldRunStep = (stepRole: OnboardingRole, userRole: OnboardingRole) => {
return (
stepRole === OnboardingRole.USER ||
(stepRole === OnboardingRole.SERVER && userRole === OnboardingRole.SERVER && !$serverConfig.isOnboarded)
);
};
$effect(() => {
const stepState = $page.url.searchParams.get('step');
const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
index = temporaryIndex === -1 ? 0 : temporaryIndex;
});
const handleDoneClicked = async () => {
if (index >= onboardingSteps.length - 1) {
await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
await retrieveServerConfig();
const previousStepIndex = $derived(
onboardingSteps.findLastIndex((step, i) => shouldRunStep(step.role, userRole) && i < index),
);
const nextStepIndex = $derived(
onboardingSteps.findIndex((step, i) => shouldRunStep(step.role, userRole) && i > index),
);
const handleNextClicked = async () => {
if (nextStepIndex == -1) {
if ($user.isAdmin) {
await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
await retrieveServerConfig();
}
await setUserOnboarding({
onboardingDto: { isOnboarded: true },
});
await goto(AppRoute.PHOTOS);
} else {
index++;
await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
await goto(
`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[nextStepIndex].name}`,
);
}
};
const handlePrevious = async () => {
if (index >= 1) {
index--;
await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
if (previousStepIndex === -1) {
return;
}
await goto(
`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[previousStepIndex].name}`,
);
};
const SvelteComponent = $derived(onboardingSteps[index].component);
onMount(async () => {
await retrieveSystemConfig();
});
const OnboardingStep = $derived(onboardingSteps[index].component);
</script>
<section id="onboarding-page" class="min-w-dvw flex min-h-dvh p-4">
@@ -61,11 +140,20 @@
<div class=" bg-gray-300 dark:bg-gray-600 rounded-md h-2">
<div
class="progress-bar bg-immich-primary dark:bg-immich-dark-primary h-2 rounded-md transition-all duration-200 ease-out"
style="width: {(index / (onboardingSteps.length - 1)) * 100}%"
style="width: {(onboardingProgress / onboardingStepCount) * 100}%"
></div>
</div>
<div class="py-8 flex place-content-center place-items-center m-auto">
<SvelteComponent onDone={handleDoneClicked} onPrevious={handlePrevious} />
<OnboardingCard
title={onboardingSteps[index].title}
icon={onboardingSteps[index].icon}
onNext={handleNextClicked}
onPrevious={handlePrevious}
previousTitle={onboardingSteps[previousStepIndex]?.title}
nextTitle={onboardingSteps[nextStepIndex]?.title}
>
<OnboardingStep />
</OnboardingCard>
</div>
</div>
</section>
+1 -1
View File
@@ -3,7 +3,7 @@ import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
await authenticate(url);
const $t = await getFormatter();