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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user