From 74438f5bd8a7ded0d33ac2d0800445bcf8cf7444 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Mon, 2 Jun 2025 16:09:13 -0500 Subject: [PATCH] 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 --- e2e/src/responses.ts | 1 + e2e/src/web/specs/auth.e2e-spec.ts | 11 +- i18n/en.json | 8 +- mobile/openapi/README.md | Bin 36422 -> 36852 bytes mobile/openapi/lib/api.dart | Bin 13042 -> 13119 bytes mobile/openapi/lib/api/users_api.dart | Bin 17962 -> 21973 bytes mobile/openapi/lib/api_client.dart | Bin 32965 -> 33137 bytes .../openapi/lib/model/login_response_dto.dart | Bin 4534 -> 4813 bytes mobile/openapi/lib/model/onboarding_dto.dart | Bin 0 -> 2831 bytes .../lib/model/onboarding_response_dto.dart | Bin 0 -> 2983 bytes open-api/immich-openapi-specs.json | 121 +++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 33 ++++ server/src/controllers/user.controller.ts | 19 +++ server/src/dtos/auth.dto.ts | 9 +- server/src/dtos/onboarding.dto.ts | 9 ++ server/src/enum.ts | 1 + server/src/services/auth.service.spec.ts | 2 + server/src/services/user.service.ts | 34 +++++ server/src/types.ts | 1 + server/test/small.factory.ts | 10 +- .../onboarding-page/onboarding-card.svelte | 52 ++++++- .../onboarding-page/onboarding-hello.svelte | 31 ++-- .../onboarding-language.svelte | 12 ++ .../onboarding-page/onboarding-privacy.svelte | 74 --------- .../onboarding-server-privacy.svelte | 35 +++++ .../onboarding-storage-template.svelte | 48 +----- .../onboarding-page/onboarding-theme.svelte | 36 +---- .../onboarding-user-privacy.svelte | 32 ++++ .../shared-components/combobox.svelte | 2 +- .../settings-language-selector.svelte | 58 +++++++ .../user-settings-page/app-settings.svelte | 36 +---- web/src/lib/models/onboarding-role.ts | 4 + web/src/lib/stores/server-config.store.ts | 17 ++- web/src/routes/auth/login/+page.svelte | 16 +- web/src/routes/auth/onboarding/+page.svelte | 142 ++++++++++++++---- web/src/routes/auth/onboarding/+page.ts | 2 +- 36 files changed, 622 insertions(+), 234 deletions(-) create mode 100644 mobile/openapi/lib/model/onboarding_dto.dart create mode 100644 mobile/openapi/lib/model/onboarding_response_dto.dart create mode 100644 server/src/dtos/onboarding.dto.ts create mode 100644 web/src/lib/components/onboarding-page/onboarding-language.svelte delete mode 100644 web/src/lib/components/onboarding-page/onboarding-privacy.svelte create mode 100644 web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte create mode 100644 web/src/lib/components/onboarding-page/onboarding-user-privacy.svelte create mode 100644 web/src/lib/components/shared-components/settings/settings-language-selector.svelte create mode 100644 web/src/lib/models/onboarding-role.ts diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 0148f2e1e..bb6d17a24 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -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', diff --git a/e2e/src/web/specs/auth.e2e-spec.ts b/e2e/src/web/specs/auth.e2e-spec.ts index 74bee64e0..0fde9a6ec 100644 --- a/e2e/src/web/specs/auth.e2e-spec.ts +++ b/e2e/src/web/specs/auth.e2e-spec.ts @@ -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/); }); diff --git a/i18n/en.json b/i18n/en.json index 3e37b2da6..98ca467c5 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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}", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 73bbe7c1ff77863eae1bcfb3378cb73b985cd0e5..4ff55e5db8d7642c919a6832f52fdf2912ba39ea 100644 GIT binary patch delta 307 zcmX>$hw00FrVaP}xKdJcQcF@pi&Kjx2YLukj`HU*hX|Ddh4S-~@)L_vGV{_k6>1c; zv|L<$Tti&7v=sC~vc>wjsrm>dlN)_SHsABR#>k$YT7qOCKMP3l^4@hdbG&BtKRoB|lkTA4NGDHk5IOG zf|v~BWPa||{Gr9EMF@2olM|d(C)e_$=;ha1xLIGIO@>cDHx?#POQ2c@7@68)kv`7e8x5+U^ Se3K&tOefb#%P-cl#^X~SbjF8{ow{KTS^%)Io;2W7?Cq1?#|iE7AFjq>uy+ylDWn;$zqvI79} CgB&{m delta 14 Vcmey^#B{WgX~Sd3&HYY~>;N}E2Q~lz diff --git a/mobile/openapi/lib/model/login_response_dto.dart b/mobile/openapi/lib/model/login_response_dto.dart index dbc82d07ba1bbb2bd8a399649448bb9f1c3dced6..82a4f9b3ed72f085b1dfd6af38a7278090d7b692 100644 GIT binary patch delta 212 zcmdm{d{%YC8Ak5RV*k9P{KTS^)Rf5=7)2(ZVcaEbR>4-GBqOs}4_Vb;HfClN*PZ253 diff --git a/mobile/openapi/lib/model/onboarding_dto.dart b/mobile/openapi/lib/model/onboarding_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..670b6a5c681ca49531e78d77c1db43b557ebc82c GIT binary patch literal 2831 zcmbVOU2oeq6n*!vxG9EO0aSV0Q<2nOi^Un*wK33o1q#CuXo<4e$)rY7HH_5%efN@* zEVf<~EI<;Gx}WEqOKLJ1O(yW_ujS&|AM?BU`&Y~P4cxwdJCEUJ0e6cfyj$G7zWsE8 zW@Py?XWCAGO@4VkqNUhMrFmK?ofe|vSJ24D@I2)e-*IWy! z=KnN8qq}4~{97}P|1H-BgKKl%JyX(HCT%KmOehM$wR1OTlT|`;lU7P@(ae@irq6#) zvyy2u8euvMssL4U$!d|{@AYVuRm>Pz$c*eF%O;Fa5aslITnvER-BE7r^^4by3~ z*nH+0_F+q|FJU?Zk4wg?i1NhUo3CH}6GjiKgQrhjc`m}RER6ocQiQay^*9djT@4~le;O>- zV~5{6R=@DxG2{o-n#g7t@QqOt!+CNFAz1~VaSNvKo%Q|k+SLrsVx`A}B;z%Dd`qb} z*G@0ahc=GH@Z)s^Oy=MR$$+p1zObs{1`4fqZJ?FgLg^J~FeYKHu-SDCvZ*T1_g)yM z$N?+0<^_&}nn9o2Ig)$}sY3eZrCA5sXv-&a+b-j`E$d^IdHZT|k5u4~?je ztM+(gIn&eo4t$3?<_uCMq&(LLmfT`O@SK>um^$SRLUQk0?zB#R>3j zy{w|1q7I}h=$>2@H&HG4!zmBSo)P6|Dd_6-fS?tj+?^C*jP3-WUOTig;}5}hc2iHr ze+d}RgZ5Irg8u$X@e0D5mTpvkRtCi3u0C|sCA1RUbgdAMzMTgsbWsv#G$7;wDiNZU zcl?_l=-hMuUWX?JJ0=4@(X}5J>=~DITser8zryyymbgU&L)UVE|1)ocF6}Xh=QrBY z#|ec(`J7P!Dd(G^aay4LvwDc(aU+Qthe|n@=l|&Cky3b!tlEQXFzyek2Ac}G4G2hx zmL9Z$c;bE{PqP{KiaImcm}ek_2`o$AbKZX=C3m} zBg>aL({}P}^2_rP-HMG=nx}=*X(1|p35{$F&r@FUEtfX#?!~rN+6FyXwPSme){RXy z|ECce-6h-N-^bS}~mqxw+$}#+3ZpD!3m3j1h%9ZW}EPFpzu) z%b@WJgex@T33>%hB8mslp#ZiNCK0B!<#~*+4;NSyz*?ya2Cv-1m+CXu8gb|huoKO! z(lD7s^Ytg5VSl#d`T{0X@OWjciYQOqz4`LRUt#ocmGJbbE7L_9mWNp$EJ;WU8*ZUw zru<#y9E)&)dKr;=B+s}pOz(+(B(&s47-tp@+D*C`9*tq+AFM`}z$$+cxn9qMcrOPL zCqE5tR_6}S_pE;5qr{N!QIjH@V&FGMNet`BDuiSee8NXCfp4tu4_B^sa288_K1g!D zLXWR0_U78@$@$QO12O*Jbp}l4;0Mouum(P}s^JC-t+s8zrF(?ZEYRSb1iZwiS24(@ zs=N+%!Z^haSgJKIaD3e4=_W&gnUtiL;via)r8u*t%851qAgrU`TpWDAJC3vYI+yhRbxB_mKoo#Eo z?*b#_xOAd6zS`mHbEe1l9Vw0t%pFXfkWyV=*g}gH!FFW&Y~qwUj7$5I8cJB6dp!(1 zJ<>E$6z2r@*2^mDE$TqKgzm~kag*4RGTaWq@~0$;AT?dR9w4+L^t(+Hj@4}v>b+wD zD|nCuXE*UAeNXVXSK34T68iHt#Ul?-b~?fR?mZzCpXz-_X+n2`o316o)wlIXYF)I% z84rm10mTT}(mVdm_q0cyzt`!J*^c28p3$`*FYFy}Y3(_vls_VO!j|}m28%uif_yyl zw&>cfRXi8ahdz!dB+4tG5>nQ8hRkV&-Usy(!{bI7GY^&YP`>}8r$("/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; diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index f1bdf160d..6c6eae15f 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -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 { + return this.service.getOnboarding(auth); + } + + @Put('me/onboarding') + @Authenticated() + async setUserOnboarding(@Auth() auth: AuthDto, @Body() Onboarding: OnboardingDto): Promise { + return this.service.setOnboarding(auth, Onboarding); + } + + @Delete('me/onboarding') + @Authenticated() + async deleteUserOnboarding(@Auth() auth: AuthDto): Promise { + await this.service.deleteOnboarding(auth); + } + @Get(':id') @Authenticated() getUser(@Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 2f3ae5c14..e94818b2b 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -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 => 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, }; } diff --git a/server/src/dtos/onboarding.dto.ts b/server/src/dtos/onboarding.dto.ts new file mode 100644 index 000000000..0028fca00 --- /dev/null +++ b/server/src/dtos/onboarding.dto.ts @@ -0,0 +1,9 @@ +import { IsBoolean, IsNotEmpty } from 'class-validator'; + +export class OnboardingDto { + @IsBoolean() + @IsNotEmpty() + isOnboarded!: boolean; +} + +export class OnboardingResponseDto extends OnboardingDto {} diff --git a/server/src/enum.ts b/server/src/enum.ts index b00b01339..e7e40eb12 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -211,6 +211,7 @@ export enum SystemMetadataKey { export enum UserMetadataKey { PREFERENCES = 'preferences', LICENSE = 'license', + ONBOARDING = 'onboarding', } export enum UserAvatarColor { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 4bc5f1ce0..a773f4a1c 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -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, }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index a0304d51a..78f49fd7a 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -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 { + const metadata = await this.userRepository.getMetadata(auth.user.id); + + const onboardingData = metadata.find( + (item): item is UserMetadataItem => item.key === UserMetadataKey.ONBOARDING, + )?.value; + + if (!onboardingData) { + return { isOnboarded: false }; + } + + return { + isOnboarded: onboardingData.isOnboarded, + }; + } + + async deleteOnboarding({ user }: AuthDto): Promise { + await this.userRepository.deleteMetadata(user.id, UserMetadataKey.ONBOARDING); + } + + async setOnboarding(auth: AuthDto, onboarding: OnboardingDto): Promise { + 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 { await this.userRepository.syncUsage(); diff --git a/server/src/types.ts b/server/src/types.ts index 9d5ba46e1..2e613c124 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -510,4 +510,5 @@ export interface UserPreferences { export interface UserMetadata extends Record> { [UserMetadataKey.PREFERENCES]: DeepPartial; [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string }; + [UserMetadataKey.ONBOARDING]: { isOnboarded: boolean }; } diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 75e36c1da..b70f02bcf 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -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 = {}) => ({ avatarColor: null, profileImagePath: '', profileChangedAt: newDate(), + metadata: [ + { + key: UserMetadataKey.ONBOARDING, + value: 'true', + }, + ] as UserMetadataItem[], ...user, }); diff --git a/web/src/lib/components/onboarding-page/onboarding-card.svelte b/web/src/lib/components/onboarding-page/onboarding-card.svelte index 54951dfa0..4a373fc31 100644 --- a/web/src/lib/components/onboarding-page/onboarding-card.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-card.svelte @@ -1,15 +1,32 @@
{/if} {@render children?.()} + +
+ {#if previousTitle} +
+ +
+ {/if} + +
+ +
+
diff --git a/web/src/lib/components/onboarding-page/onboarding-hello.svelte b/web/src/lib/components/onboarding-page/onboarding-hello.svelte index 70df619ae..f1b1516bb 100644 --- a/web/src/lib/components/onboarding-page/onboarding-hello.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-hello.svelte @@ -1,28 +1,21 @@ - - -

+

+ +

{$t('onboarding_welcome_user', { values: { user: $user.name } })}

-

{$t('onboarding_welcome_description')}

- -
- -
- +

+ {userRole == OnboardingRole.SERVER + ? $t('onboarding_server_welcome_description') + : $t('onboarding_user_welcome_description')} +

+
diff --git a/web/src/lib/components/onboarding-page/onboarding-language.svelte b/web/src/lib/components/onboarding-page/onboarding-language.svelte new file mode 100644 index 000000000..a37b026f1 --- /dev/null +++ b/web/src/lib/components/onboarding-page/onboarding-language.svelte @@ -0,0 +1,12 @@ + + +
+

+ {$t('onboarding_locale_description')} +

+ + +
diff --git a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-privacy.svelte deleted file mode 100644 index 12f4084fb..000000000 --- a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - - -

- {$t('onboarding_privacy_description')} -

- - {#if config && $user} - - {#if config} - - -
-
- -
-
- -
-
- {/if} -
- {/if} -
diff --git a/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte new file mode 100644 index 000000000..a4af880fb --- /dev/null +++ b/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte @@ -0,0 +1,35 @@ + + +
+

+ {$t('onboarding_privacy_description')} +

+ + {#if $systemConfig} + + + {/if} +
diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte index de2ce7e98..baa45779e 100644 --- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte @@ -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>(); @@ -24,9 +13,13 @@ onMount(async () => { config = await getConfig(); }); + + export const save = async () => { + await adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate }); + }; - +

{#snippet children({ message })} @@ -48,36 +41,9 @@ onSave={(config) => adminSettingsComponent?.handleSave(config)} onReset={(options) => adminSettingsComponent?.handleReset(options)} duration={0} - > -

-
- -
-
- -
-
- + /> {/if} {/snippet} {/if} - +
diff --git a/web/src/lib/components/onboarding-page/onboarding-theme.svelte b/web/src/lib/components/onboarding-page/onboarding-theme.svelte index b128a9755..26e8fd9c7 100644 --- a/web/src/lib/components/onboarding-page/onboarding-theme.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-theme.svelte @@ -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(); - -
-

{$t('onboarding_theme_description')}

-
+
+

{$t('onboarding_theme_description')}

-
+
- -
-
- -
-
- +
diff --git a/web/src/lib/components/onboarding-page/onboarding-user-privacy.svelte b/web/src/lib/components/onboarding-page/onboarding-user-privacy.svelte new file mode 100644 index 000000000..d65ade1b1 --- /dev/null +++ b/web/src/lib/components/onboarding-page/onboarding-user-privacy.svelte @@ -0,0 +1,32 @@ + + +
+

+ {$t('onboarding_privacy_description')} +

+ + +
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 91282ae9b..8d5800e9a 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -348,7 +348,7 @@
    + 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)); + + +
    + {#if showSettingDescription} +
    +
    + +
    + +

    {$t('language_setting_description')}

    +
    + {/if} + + value === closestLanguage) || defaultLangOption} + placeholder={$t('language')} + onSelect={(event) => handleLanguageChange(event?.value)} + options={langOptions} + /> +
    diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte index f1d8e1478..adb37d5d9 100644 --- a/web/src/lib/components/user-settings-page/app-settings.svelte +++ b/web/src/lib/components/user-settings-page/app-settings.svelte @@ -1,22 +1,20 @@
    @@ -103,14 +82,7 @@
- value === closestLanguage) || defaultLangOption} - options={langOptions} - title={$t('language')} - subtitle={$t('language_setting_description')} - onSelect={(combobox) => handleLanguageChange(combobox?.value)} - /> +
diff --git a/web/src/lib/models/onboarding-role.ts b/web/src/lib/models/onboarding-role.ts new file mode 100644 index 000000000..4efc30793 --- /dev/null +++ b/web/src/lib/models/onboarding-role.ts @@ -0,0 +1,4 @@ +export enum OnboardingRole { + SERVER = 'server', + USER = 'user', +} diff --git a/web/src/lib/stores/server-config.store.ts b/web/src/lib/stores/server-config.store.ts index 254db7194..ce2d8c284 100644 --- a/web/src/lib/stores/server-config.store.ts +++ b/web/src/lib/stores/server-config.store.ts @@ -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({ publicUsers: true, }); +export type SystemConfig = SystemConfigDto & { loaded: boolean }; +export const systemConfig = writable(); + 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 })); +}; diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index 7937a55f8..fca888006 100644 --- a/web/src/routes/auth/login/+page.svelte +++ b/web/src/routes/auth/login/+page.svelte @@ -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) { diff --git a/web/src/routes/auth/onboarding/+page.svelte b/web/src/routes/auth/onboarding/+page.svelte index 091681002..2978e4fd2 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -1,17 +1,21 @@
@@ -61,11 +140,20 @@
- + + +
diff --git a/web/src/routes/auth/onboarding/+page.ts b/web/src/routes/auth/onboarding/+page.ts index 86c19c10a..66cb3de2c 100644 --- a/web/src/routes/auth/onboarding/+page.ts +++ b/web/src/routes/auth/onboarding/+page.ts @@ -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();