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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 622 additions and 234 deletions

View File

@ -103,6 +103,7 @@ export const loginResponseDto = {
accessToken: expect.any(String),
name: 'Immich Admin',
isAdmin: true,
isOnboarded: false,
profileImagePath: '',
shouldChangePassword: true,
userEmail: 'admin@immich.cloud',

View File

@ -33,7 +33,9 @@ test.describe('Registration', () => {
// onboarding
await expect(page).toHaveURL('/auth/onboarding');
await page.getByRole('button', { name: 'Theme' }).click();
await page.getByRole('button', { name: 'Privacy' }).click();
await page.getByRole('button', { name: 'Language' }).click();
await page.getByRole('button', { name: 'Server Privacy' }).click();
await page.getByRole('button', { name: 'User Privacy' }).click();
await page.getByRole('button', { name: 'Storage Template' }).click();
await page.getByRole('button', { name: 'Done' }).click();
@ -77,6 +79,13 @@ test.describe('Registration', () => {
await page.getByLabel('Password').fill('new-password');
await page.getByRole('button', { name: 'Login' }).click();
// onboarding
await expect(page).toHaveURL('/auth/onboarding');
await page.getByRole('button', { name: 'Theme' }).click();
await page.getByRole('button', { name: 'Language' }).click();
await page.getByRole('button', { name: 'User Privacy' }).click();
await page.getByRole('button', { name: 'Done' }).click();
// success
await expect(page).toHaveURL(/\/photos/);
});

View File

@ -1294,9 +1294,11 @@
"oldest_first": "Oldest first",
"on_this_device": "On this device",
"onboarding": "Onboarding",
"onboarding_privacy_description": "The following (optional) features rely on external services, and can be disabled at any time in the administration settings.",
"onboarding_locale_description": "Select your preferred language. You can change this later in your settings.",
"onboarding_privacy_description": "The following (optional) features rely on external services, and can be disabled at any time in settings.",
"onboarding_server_welcome_description": "Let's get your instance set up with some common settings.",
"onboarding_theme_description": "Choose a color theme for your instance. You can change this later in your settings.",
"onboarding_welcome_description": "Let's get your instance set up with some common settings.",
"onboarding_user_welcome_description": "Let's get you started!",
"onboarding_welcome_user": "Welcome, {user}",
"online": "Online",
"only_favorites": "Only favorites",
@ -1608,6 +1610,7 @@
"server_info_box_server_url": "Server URL",
"server_offline": "Server Offline",
"server_online": "Server Online",
"server_privacy": "Server Privacy",
"server_stats": "Server Stats",
"server_version": "Server Version",
"set": "Set",
@ -1879,6 +1882,7 @@
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}",
"user_pin_code_settings": "PIN Code",
"user_pin_code_settings_description": "Manage your PIN code",
"user_privacy": "User Privacy",
"user_purchase_settings": "Purchase",
"user_purchase_settings_description": "Manage your purchase",
"user_role_set": "Set {user} as {role}",

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -7922,6 +7922,101 @@
]
}
},
"/users/me/onboarding": {
"delete": {
"operationId": "deleteUserOnboarding",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Users"
]
},
"get": {
"operationId": "getUserOnboarding",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OnboardingResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Users"
]
},
"put": {
"operationId": "setUserOnboarding",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OnboardingDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OnboardingResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Users"
]
}
},
"/users/me/preferences": {
"get": {
"operationId": "getMyPreferences",
@ -10404,6 +10499,9 @@
"isAdmin": {
"type": "boolean"
},
"isOnboarded": {
"type": "boolean"
},
"name": {
"type": "string"
},
@ -10423,6 +10521,7 @@
"required": [
"accessToken",
"isAdmin",
"isOnboarded",
"name",
"profileImagePath",
"shouldChangePassword",
@ -11067,6 +11166,28 @@
],
"type": "object"
},
"OnboardingDto": {
"properties": {
"isOnboarded": {
"type": "boolean"
}
},
"required": [
"isOnboarded"
],
"type": "object"
},
"OnboardingResponseDto": {
"properties": {
"isOnboarded": {
"type": "boolean"
}
},
"required": [
"isOnboarded"
],
"type": "object"
},
"PartnerDirection": {
"enum": [
"shared-by",

View File

@ -512,6 +512,7 @@ export type LoginCredentialDto = {
export type LoginResponseDto = {
accessToken: string;
isAdmin: boolean;
isOnboarded: boolean;
name: string;
profileImagePath: string;
shouldChangePassword: boolean;
@ -1470,6 +1471,12 @@ export type UserUpdateMeDto = {
name?: string;
password?: string;
};
export type OnboardingResponseDto = {
isOnboarded: boolean;
};
export type OnboardingDto = {
isOnboarded: boolean;
};
export type CreateProfileImageDto = {
file: Blob;
};
@ -3582,6 +3589,32 @@ export function setUserLicense({ licenseKeyDto }: {
body: licenseKeyDto
})));
}
export function deleteUserOnboarding(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/users/me/onboarding", {
...opts,
method: "DELETE"
}));
}
export function getUserOnboarding(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: OnboardingResponseDto;
}>("/users/me/onboarding", {
...opts
}));
}
export function setUserOnboarding({ onboardingDto }: {
onboardingDto: OnboardingDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: OnboardingResponseDto;
}>("/users/me/onboarding", oazapfts.json({
...opts,
method: "PUT",
body: onboardingDto
})));
}
export function getMyPreferences(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;

View File

@ -17,6 +17,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
@ -87,6 +88,24 @@ export class UserController {
await this.service.deleteLicense(auth);
}
@Get('me/onboarding')
@Authenticated()
getUserOnboarding(@Auth() auth: AuthDto): Promise<OnboardingResponseDto> {
return this.service.getOnboarding(auth);
}
@Put('me/onboarding')
@Authenticated()
async setUserOnboarding(@Auth() auth: AuthDto, @Body() Onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
return this.service.setOnboarding(auth, Onboarding);
}
@Delete('me/onboarding')
@Authenticated()
async deleteUserOnboarding(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteOnboarding(auth);
}
@Get(':id')
@Authenticated()
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {

View File

@ -2,7 +2,8 @@ import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import { ImmichCookie } from 'src/enum';
import { ImmichCookie, UserMetadataKey } from 'src/enum';
import { UserMetadataItem } from 'src/types';
import { Optional, PinCode, toEmail } from 'src/validation';
export type CookieResponse = {
@ -39,9 +40,14 @@ export class LoginResponseDto {
profileImagePath!: string;
isAdmin!: boolean;
shouldChangePassword!: boolean;
isOnboarded!: boolean;
}
export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
const onboardingMetadata = entity.metadata.find(
(item): item is UserMetadataItem<UserMetadataKey.ONBOARDING> => item.key === UserMetadataKey.ONBOARDING,
)?.value;
return {
accessToken,
userId: entity.id,
@ -50,6 +56,7 @@ export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginR
isAdmin: entity.isAdmin,
profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword,
isOnboarded: onboardingMetadata?.isOnboarded ?? false,
};
}

View File

@ -0,0 +1,9 @@
import { IsBoolean, IsNotEmpty } from 'class-validator';
export class OnboardingDto {
@IsBoolean()
@IsNotEmpty()
isOnboarded!: boolean;
}
export class OnboardingResponseDto extends OnboardingDto {}

View File

@ -211,6 +211,7 @@ export enum SystemMetadataKey {
export enum UserMetadataKey {
PREFERENCES = 'preferences',
LICENSE = 'license',
ONBOARDING = 'onboarding',
}
export enum UserAvatarColor {

View File

@ -28,6 +28,7 @@ const oauthResponse = ({
name,
profileImagePath,
isAdmin: false,
isOnboarded: false,
shouldChangePassword: false,
});
@ -101,6 +102,7 @@ describe(AuthService.name, () => {
name: user.name,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
isOnboarded: false,
shouldChangePassword: user.shouldChangePassword,
});

View File

@ -6,6 +6,7 @@ import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
@ -179,6 +180,39 @@ export class UserService extends BaseService {
return { ...license, activatedAt };
}
async getOnboarding(auth: AuthDto): Promise<OnboardingResponseDto> {
const metadata = await this.userRepository.getMetadata(auth.user.id);
const onboardingData = metadata.find(
(item): item is UserMetadataItem<UserMetadataKey.ONBOARDING> => item.key === UserMetadataKey.ONBOARDING,
)?.value;
if (!onboardingData) {
return { isOnboarded: false };
}
return {
isOnboarded: onboardingData.isOnboarded,
};
}
async deleteOnboarding({ user }: AuthDto): Promise<void> {
await this.userRepository.deleteMetadata(user.id, UserMetadataKey.ONBOARDING);
}
async setOnboarding(auth: AuthDto, onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
await this.userRepository.upsertMetadata(auth.user.id, {
key: UserMetadataKey.ONBOARDING,
value: {
isOnboarded: onboarding.isOnboarded,
},
});
return {
isOnboarded: onboarding.isOnboarded,
};
}
@OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK })
async handleUserSyncUsage(): Promise<JobStatus> {
await this.userRepository.syncUsage();

View File

@ -510,4 +510,5 @@ export interface UserPreferences {
export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {
[UserMetadataKey.PREFERENCES]: DeepPartial<UserPreferences>;
[UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string };
[UserMetadataKey.ONBOARDING]: { isOnboarded: boolean };
}

View File

@ -15,8 +15,8 @@ import {
} from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserStatus } from 'src/enum';
import { OnThisDayData } from 'src/types';
import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserMetadataKey, UserStatus } from 'src/enum';
import { OnThisDayData, UserMetadataItem } from 'src/types';
export const newUuid = () => randomUUID() as string;
export const newUuids = () =>
@ -146,6 +146,12 @@ const userFactory = (user: Partial<User> = {}) => ({
avatarColor: null,
profileImagePath: '',
profileChangedAt: newDate(),
metadata: [
{
key: UserMetadataKey.ONBOARDING,
value: 'true',
},
] as UserMetadataItem[],
...user,
});

View File

@ -1,15 +1,32 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { Button } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiCheck } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
title?: string | undefined;
icon?: string | undefined;
children?: Snippet;
previousTitle?: string | undefined;
nextTitle?: string | undefined;
onNext?: () => void;
onPrevious?: () => void;
onLeave?: () => void;
}
let { title = undefined, icon = undefined, children }: Props = $props();
let {
title = undefined,
icon = undefined,
children,
previousTitle,
nextTitle,
onLeave,
onNext,
onPrevious,
}: Props = $props();
</script>
<div
@ -30,4 +47,37 @@
</div>
{/if}
{@render children?.()}
<div class="flex pt-4">
{#if previousTitle}
<div class="w-full flex place-content-start">
<Button
shape="round"
leadingIcon={mdiArrowLeft}
class="flex gap-2 place-content-center"
onclick={() => {
onLeave?.();
onPrevious?.();
}}
>
<p>{previousTitle}</p>
</Button>
</div>
{/if}
<div class="flex w-full place-content-end">
<Button
shape="round"
trailingIcon={nextTitle ? mdiArrowRight : mdiCheck}
onclick={() => {
onLeave?.();
onNext?.();
}}
>
<span class="flex place-content-center place-items-center gap-2">
{nextTitle ?? $t('done')}
</span>
</Button>
</div>
</div>
</div>

View File

@ -1,28 +1,21 @@
<script lang="ts">
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
import { user } from '$lib/stores/user.store';
import { Button } from '@immich/ui';
import { mdiArrowRight } from '@mdi/js';
import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
import { OnboardingRole } from '$lib/models/onboarding-role';
import { serverConfig } from '$lib/stores/server-config.store';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER);
</script>
<OnboardingCard>
<ImmichLogo noText class="h-[50px]" />
<p class="font-medium text-6xl my-6 text-immich-primary dark:text-immich-dark-primary">
<div class="gap-4">
<ImmichLogo noText class="h-[100px] mb-2" />
<p class="font-medium mb-6 text-6xl text-immich-primary dark:text-immich-dark-primary">
{$t('onboarding_welcome_user', { values: { user: $user.name } })}
</p>
<p class="text-3xl pb-6 font-light">{$t('onboarding_welcome_description')}</p>
<div class="w-full flex place-content-end">
<Button shape="round" trailingIcon={mdiArrowRight} class="flex gap-2 place-content-center" onclick={onDone}>
<p>{$t('theme')}</p>
</Button>
</div>
</OnboardingCard>
<p class="text-3xl pb-6 font-light">
{userRole == OnboardingRole.SERVER
? $t('onboarding_server_welcome_description')
: $t('onboarding_user_welcome_description')}
</p>
</div>

View File

@ -0,0 +1,12 @@
<script lang="ts">
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
import { t } from 'svelte-i18n';
</script>
<div class="flex flex-col gap-4">
<p>
{$t('onboarding_locale_description')}
</p>
<SettingsLanguageSelector />
</div>

View File

@ -1,74 +0,0 @@
<script lang="ts">
import AdminSettings from '$lib/components/admin-page/settings/admin-settings.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { user } from '$lib/stores/user.store';
import { getConfig, type SystemConfigDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiIncognito } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
interface Props {
onDone: () => void;
onPrevious: () => void;
}
let { onDone, onPrevious }: Props = $props();
let config: SystemConfigDto | null = $state(null);
let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
onMount(async () => {
config = await getConfig();
});
</script>
<OnboardingCard title={$t('privacy')} icon={mdiIncognito}>
<p>
{$t('onboarding_privacy_description')}
</p>
{#if config && $user}
<AdminSettings bind:config bind:this={adminSettingsComponent}>
{#if config}
<SettingSwitch
title={$t('admin.map_settings')}
subtitle={$t('admin.map_implications')}
bind:checked={config.map.enabled}
/>
<SettingSwitch
title={$t('admin.version_check_settings')}
subtitle={$t('admin.version_check_implications')}
bind:checked={config.newVersionCheck.enabled}
/>
<div class="flex pt-4">
<div class="w-full flex place-content-start">
<Button
shape="round"
leadingIcon={mdiArrowLeft}
class="flex gap-2 place-content-center"
onclick={() => onPrevious()}
>
<p>{$t('theme')}</p>
</Button>
</div>
<div class="flex w-full place-content-end">
<Button
shape="round"
trailingIcon={mdiArrowRight}
onclick={() => {
adminSettingsComponent?.handleSave({ map: config?.map, newVersionCheck: config?.newVersionCheck });
onDone();
}}
>
<span class="flex place-content-center place-items-center gap-2">
{$t('admin.storage_template_settings')}
</span>
</Button>
</div>
</div>
{/if}
</AdminSettings>
{/if}
</OnboardingCard>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { systemConfig } from '$lib/stores/server-config.store';
import { updateConfig } from '@immich/sdk';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
onDestroy(async () => {
const cfg = get(systemConfig);
await updateConfig({
systemConfigDto: cfg,
});
});
</script>
<div class="flex flex-col gap-4">
<p>
{$t('onboarding_privacy_description')}
</p>
{#if $systemConfig}
<SettingSwitch
title={$t('admin.map_settings')}
subtitle={$t('admin.map_implications')}
bind:checked={$systemConfig.map.enabled}
/>
<SettingSwitch
title={$t('admin.version_check_settings')}
subtitle={$t('admin.version_check_implications')}
bind:checked={$systemConfig.newVersionCheck.enabled}
/>
{/if}
</div>

View File

@ -5,18 +5,7 @@
import { featureFlags } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { getConfig, type SystemConfigDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiArrowLeft, mdiCheck, mdiHarddisk } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
interface Props {
onDone: () => void;
onPrevious: () => void;
}
let { onDone, onPrevious }: Props = $props();
let config: SystemConfigDto | undefined = $state();
let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
@ -24,9 +13,13 @@
onMount(async () => {
config = await getConfig();
});
export const save = async () => {
await adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate });
};
</script>
<OnboardingCard title={$t('admin.storage_template_settings')} icon={mdiHarddisk}>
<div class="flex flex-col">
<p>
<FormatMessage key="admin.storage_template_onboarding_description">
{#snippet children({ message })}
@ -48,36 +41,9 @@
onSave={(config) => adminSettingsComponent?.handleSave(config)}
onReset={(options) => adminSettingsComponent?.handleReset(options)}
duration={0}
>
<div class="flex pt-4">
<div class="w-full flex place-content-start">
<Button
shape="round"
leadingIcon={mdiArrowLeft}
class="flex gap-2 place-content-center"
onclick={() => onPrevious()}
>
<p>{$t('privacy')}</p>
</Button>
</div>
<div class="flex w-full place-content-end">
<Button
shape="round"
trailingIcon={mdiCheck}
onclick={() => {
adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate });
onDone();
}}
>
<span class="flex place-content-center place-items-center gap-2">
{$t('done')}
</span>
</Button>
</div>
</div>
</StorageTemplateSettings>
/>
{/if}
{/snippet}
</AdminSettings>
{/if}
</OnboardingCard>
</div>

View File

@ -3,27 +3,16 @@
import Icon from '$lib/components/elements/icon.svelte';
import { Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import { Button } from '@immich/ui';
import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js';
import { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
</script>
<OnboardingCard icon={mdiThemeLightDark} title={$t('color_theme')}>
<div>
<p class="pb-6 font-light">{$t('onboarding_theme_description')}</p>
</div>
<div class="flex flex-col gap-4">
<p>{$t('onboarding_theme_description')}</p>
<div class="flex gap-4 mb-6">
<div class="flex gap-4">
<button
type="button"
class="w-1/2 aspect-square bg-light dark:bg-dark rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent"
class="w-1/2 aspect-square bg-light dark:bg-dark rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-primary dark:border dark:border-transparent"
onclick={() => themeManager.setTheme(Theme.LIGHT)}
>
<div
@ -35,7 +24,7 @@
</button>
<button
type="button"
class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent"
class="dark w-1/2 aspect-square bg-light rounded-3xl dark:border-[3px] dark:border-immich-dark-primary border border-transparent"
onclick={() => themeManager.setTheme(Theme.DARK)}
>
<div
@ -46,17 +35,4 @@
</div>
</button>
</div>
<div class="flex">
<div class="w-full flex place-content-end">
<Button
trailingIcon={mdiArrowRight}
shape="round"
class="flex gap-2 place-content-center"
onclick={() => onDone()}
>
<p>{$t('privacy')}</p>
</Button>
</div>
</div>
</OnboardingCard>
</div>

View File

@ -0,0 +1,32 @@
<script lang="ts">
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { preferences } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { updateMyPreferences } from '@immich/sdk';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
let gCastEnabled = $state($preferences?.cast?.gCastEnabled ?? false);
onDestroy(async () => {
try {
const data = await updateMyPreferences({
userPreferencesUpdateDto: {
cast: { gCastEnabled },
},
});
$preferences = { ...data };
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}
});
</script>
<div class="flex flex-col gap-4">
<p>
{$t('onboarding_privacy_description')}
</p>
<SettingSwitch title={$t('gcast_enabled')} subtitle={$t('gcast_enabled_description')} bind:checked={gCastEnabled} />
</div>

View File

@ -348,7 +348,7 @@
<ul
role="listbox"
id={listboxId}
transition:fly={{ duration: 250 }}
in:fly={{ duration: 250 }}
class="fixed z-1 text-start text-sm w-full overflow-y-auto bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-900"
class:rounded-b-xl={dropdownDirection === 'bottom'}
class:rounded-t-xl={dropdownDirection === 'top'}

View File

@ -0,0 +1,58 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import Combobox from '$lib/components/shared-components/combobox.svelte';
import { defaultLang, langs } from '$lib/constants';
import { lang } from '$lib/stores/preferences.store';
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
import { locale as i18nLocale, t } from 'svelte-i18n';
interface Props {
showSettingDescription?: boolean;
}
let { showSettingDescription = false }: Props = $props();
const langOptions = langs
.map((lang) => ({ label: lang.name, value: lang.code }))
.sort((a, b) => {
if (b.label.startsWith('Development')) {
return -1;
}
return a.label.localeCompare(b.label);
});
const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
const handleLanguageChange = async (newLang: string | undefined) => {
if (newLang) {
$lang = newLang;
await i18nLocale.set(newLang);
await invalidateAll();
}
};
let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
</script>
<div class={showSettingDescription ? 'grid grid-cols-2' : ''}>
{#if showSettingDescription}
<div>
<div class="flex h-[26px] place-items-center gap-1">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for={$t('language')}>
{$t('language')}
</label>
</div>
<p class="text-sm dark:text-immich-dark-fg">{$t('language_setting_description')}</p>
</div>
{/if}
<Combobox
label={$t('language')}
hideLabel={true}
selectedOption={langOptions.find(({ value }) => value === closestLanguage) || defaultLangOption}
placeholder={$t('language')}
onSelect={(event) => handleLanguageChange(event?.value)}
options={langOptions}
/>
</div>

View File

@ -1,22 +1,20 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { defaultLang, fallbackLocale, langs, locales } from '$lib/constants';
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
import { fallbackLocale, locales } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte';
import {
alwaysLoadOriginalFile,
lang,
locale,
loopVideo,
playVideoThumbnailOnHover,
showDeleteModal,
} from '$lib/stores/preferences.store';
import { findLocale } from '$lib/utils';
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
import { onMount } from 'svelte';
import { locale as i18nLocale, t } from 'svelte-i18n';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let time = $state(new Date());
@ -44,24 +42,6 @@
$locale = $locale ? undefined : fallbackLocale.code;
};
const langOptions = langs
.map((lang) => ({ label: lang.name, value: lang.code }))
.sort((a, b) => {
if (b.label.startsWith('Development')) {
return -1;
}
return a.label.localeCompare(b.label);
});
const defaultLangOption = { label: defaultLang.name, value: defaultLang.code };
const handleLanguageChange = async (newLang: string | undefined) => {
if (newLang) {
$lang = newLang;
await i18nLocale.set(newLang);
await invalidateAll();
}
};
const handleLocaleChange = (newLocale: string | undefined) => {
if (newLocale) {
$locale = newLocale;
@ -87,7 +67,6 @@
value: findLocale(editedLocale).code || fallbackLocale.code,
label: findLocale(editedLocale).name || fallbackLocale.name,
});
let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
</script>
<section class="my-4">
@ -103,14 +82,7 @@
</div>
<div class="ms-4">
<SettingCombobox
comboboxPlaceholder={$t('language')}
selectedOption={langOptions.find(({ value }) => value === closestLanguage) || defaultLangOption}
options={langOptions}
title={$t('language')}
subtitle={$t('language_setting_description')}
onSelect={(combobox) => handleLanguageChange(combobox?.value)}
/>
<SettingsLanguageSelector showSettingDescription />
</div>
<div class="ms-4">

View File

@ -0,0 +1,4 @@
export enum OnboardingRole {
SERVER = 'server',
USER = 'user',
}

View File

@ -1,4 +1,11 @@
import { getServerConfig, getServerFeatures, type ServerConfigDto, type ServerFeaturesDto } from '@immich/sdk';
import {
getConfig,
getServerConfig,
getServerFeatures,
type ServerConfigDto,
type ServerFeaturesDto,
type SystemConfigDto,
} from '@immich/sdk';
import { writable } from 'svelte/store';
export type FeatureFlags = ServerFeaturesDto & { loaded: boolean };
@ -37,9 +44,17 @@ export const serverConfig = writable<ServerConfig>({
publicUsers: true,
});
export type SystemConfig = SystemConfigDto & { loaded: boolean };
export const systemConfig = writable<SystemConfig>();
export const retrieveServerConfig = async () => {
const [flags, config] = await Promise.all([getServerFeatures(), getServerConfig()]);
featureFlags.update(() => ({ ...flags, loaded: true }));
serverConfig.update(() => ({ ...config, loaded: true }));
};
export const retrieveSystemConfig = async () => {
const config = await getConfig();
systemConfig.update(() => ({ ...config, loaded: true }));
};

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) {

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>

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();