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 | 5 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/users_api.dart | 121 +++++++++++++++ mobile/openapi/lib/api_client.dart | 4 + .../openapi/lib/model/login_response_dto.dart | 10 +- mobile/openapi/lib/model/onboarding_dto.dart | 99 ++++++++++++ .../lib/model/onboarding_response_dto.dart | 99 ++++++++++++ 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, 961 insertions(+), 235 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 0148f2e1e93..bb6d17a2483 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 74bee64e0a9..0fde9a6ec64 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 3e37b2da655..98ca467c51e 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 73bbe7c1ff7..4ff55e5db8d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -247,13 +247,16 @@ Class | Method | HTTP request | Description *UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image | *UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | *UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | +*UsersApi* | [**deleteUserOnboarding**](doc//UsersApi.md#deleteuseronboarding) | **DELETE** /users/me/onboarding | *UsersApi* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | *UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | *UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | *UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} | *UsersApi* | [**getUserLicense**](doc//UsersApi.md#getuserlicense) | **GET** /users/me/license | +*UsersApi* | [**getUserOnboarding**](doc//UsersApi.md#getuseronboarding) | **GET** /users/me/onboarding | *UsersApi* | [**searchUsers**](doc//UsersApi.md#searchusers) | **GET** /users | *UsersApi* | [**setUserLicense**](doc//UsersApi.md#setuserlicense) | **PUT** /users/me/license | +*UsersApi* | [**setUserOnboarding**](doc//UsersApi.md#setuseronboarding) | **PUT** /users/me/onboarding | *UsersApi* | [**updateMyPreferences**](doc//UsersApi.md#updatemypreferences) | **PUT** /users/me/preferences | *UsersApi* | [**updateMyUser**](doc//UsersApi.md#updatemyuser) | **PUT** /users/me | *UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users | @@ -385,6 +388,8 @@ Class | Method | HTTP request | Description - [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthTokenEndpointAuthMethod](doc//OAuthTokenEndpointAuthMethod.md) - [OnThisDayDto](doc//OnThisDayDto.md) + - [OnboardingDto](doc//OnboardingDto.md) + - [OnboardingResponseDto](doc//OnboardingResponseDto.md) - [PartnerDirection](doc//PartnerDirection.md) - [PartnerResponseDto](doc//PartnerResponseDto.md) - [PeopleResponse](doc//PeopleResponse.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 846db953dcb..87d14248ebd 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -177,6 +177,8 @@ part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_config_dto.dart'; part 'model/o_auth_token_endpoint_auth_method.dart'; part 'model/on_this_day_dto.dart'; +part 'model/onboarding_dto.dart'; +part 'model/onboarding_response_dto.dart'; part 'model/partner_direction.dart'; part 'model/partner_response_dto.dart'; part 'model/people_response.dart'; diff --git a/mobile/openapi/lib/api/users_api.dart b/mobile/openapi/lib/api/users_api.dart index a48ec54cfe5..cd31617e74c 100644 --- a/mobile/openapi/lib/api/users_api.dart +++ b/mobile/openapi/lib/api/users_api.dart @@ -139,6 +139,39 @@ class UsersApi { } } + /// Performs an HTTP 'DELETE /users/me/onboarding' operation and returns the [Response]. + Future deleteUserOnboardingWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/users/me/onboarding'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future deleteUserOnboarding() async { + final response = await deleteUserOnboardingWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /users/me/preferences' operation and returns the [Response]. Future getMyPreferencesWithHttpInfo() async { // ignore: prefer_const_declarations @@ -358,6 +391,47 @@ class UsersApi { return null; } + /// Performs an HTTP 'GET /users/me/onboarding' operation and returns the [Response]. + Future getUserOnboardingWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/users/me/onboarding'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getUserOnboarding() async { + final response = await getUserOnboardingWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'OnboardingResponseDto',) as OnboardingResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /users' operation and returns the [Response]. Future searchUsersWithHttpInfo() async { // ignore: prefer_const_declarations @@ -449,6 +523,53 @@ class UsersApi { return null; } + /// Performs an HTTP 'PUT /users/me/onboarding' operation and returns the [Response]. + /// Parameters: + /// + /// * [OnboardingDto] onboardingDto (required): + Future setUserOnboardingWithHttpInfo(OnboardingDto onboardingDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/users/me/onboarding'; + + // ignore: prefer_final_locals + Object? postBody = onboardingDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [OnboardingDto] onboardingDto (required): + Future setUserOnboarding(OnboardingDto onboardingDto,) async { + final response = await setUserOnboardingWithHttpInfo(onboardingDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'OnboardingResponseDto',) as OnboardingResponseDto; + + } + return null; + } + /// Performs an HTTP 'PUT /users/me/preferences' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 2657cece1cc..46936fa88b4 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -410,6 +410,10 @@ class ApiClient { return OAuthTokenEndpointAuthMethodTypeTransformer().decode(value); case 'OnThisDayDto': return OnThisDayDto.fromJson(value); + case 'OnboardingDto': + return OnboardingDto.fromJson(value); + case 'OnboardingResponseDto': + return OnboardingResponseDto.fromJson(value); case 'PartnerDirection': return PartnerDirectionTypeTransformer().decode(value); case 'PartnerResponseDto': diff --git a/mobile/openapi/lib/model/login_response_dto.dart b/mobile/openapi/lib/model/login_response_dto.dart index dbc82d07ba1..82a4f9b3ed7 100644 --- a/mobile/openapi/lib/model/login_response_dto.dart +++ b/mobile/openapi/lib/model/login_response_dto.dart @@ -15,6 +15,7 @@ class LoginResponseDto { LoginResponseDto({ required this.accessToken, required this.isAdmin, + required this.isOnboarded, required this.name, required this.profileImagePath, required this.shouldChangePassword, @@ -26,6 +27,8 @@ class LoginResponseDto { bool isAdmin; + bool isOnboarded; + String name; String profileImagePath; @@ -40,6 +43,7 @@ class LoginResponseDto { bool operator ==(Object other) => identical(this, other) || other is LoginResponseDto && other.accessToken == accessToken && other.isAdmin == isAdmin && + other.isOnboarded == isOnboarded && other.name == name && other.profileImagePath == profileImagePath && other.shouldChangePassword == shouldChangePassword && @@ -51,6 +55,7 @@ class LoginResponseDto { // ignore: unnecessary_parenthesis (accessToken.hashCode) + (isAdmin.hashCode) + + (isOnboarded.hashCode) + (name.hashCode) + (profileImagePath.hashCode) + (shouldChangePassword.hashCode) + @@ -58,12 +63,13 @@ class LoginResponseDto { (userId.hashCode); @override - String toString() => 'LoginResponseDto[accessToken=$accessToken, isAdmin=$isAdmin, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]'; + String toString() => 'LoginResponseDto[accessToken=$accessToken, isAdmin=$isAdmin, isOnboarded=$isOnboarded, name=$name, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, userEmail=$userEmail, userId=$userId]'; Map toJson() { final json = {}; json[r'accessToken'] = this.accessToken; json[r'isAdmin'] = this.isAdmin; + json[r'isOnboarded'] = this.isOnboarded; json[r'name'] = this.name; json[r'profileImagePath'] = this.profileImagePath; json[r'shouldChangePassword'] = this.shouldChangePassword; @@ -83,6 +89,7 @@ class LoginResponseDto { return LoginResponseDto( accessToken: mapValueOfType(json, r'accessToken')!, isAdmin: mapValueOfType(json, r'isAdmin')!, + isOnboarded: mapValueOfType(json, r'isOnboarded')!, name: mapValueOfType(json, r'name')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, @@ -137,6 +144,7 @@ class LoginResponseDto { static const requiredKeys = { 'accessToken', 'isAdmin', + 'isOnboarded', 'name', 'profileImagePath', 'shouldChangePassword', diff --git a/mobile/openapi/lib/model/onboarding_dto.dart b/mobile/openapi/lib/model/onboarding_dto.dart new file mode 100644 index 00000000000..670b6a5c681 --- /dev/null +++ b/mobile/openapi/lib/model/onboarding_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class OnboardingDto { + /// Returns a new [OnboardingDto] instance. + OnboardingDto({ + required this.isOnboarded, + }); + + bool isOnboarded; + + @override + bool operator ==(Object other) => identical(this, other) || other is OnboardingDto && + other.isOnboarded == isOnboarded; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (isOnboarded.hashCode); + + @override + String toString() => 'OnboardingDto[isOnboarded=$isOnboarded]'; + + Map toJson() { + final json = {}; + json[r'isOnboarded'] = this.isOnboarded; + return json; + } + + /// Returns a new [OnboardingDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static OnboardingDto? fromJson(dynamic value) { + upgradeDto(value, "OnboardingDto"); + if (value is Map) { + final json = value.cast(); + + return OnboardingDto( + isOnboarded: mapValueOfType(json, r'isOnboarded')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = OnboardingDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = OnboardingDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of OnboardingDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = OnboardingDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'isOnboarded', + }; +} + diff --git a/mobile/openapi/lib/model/onboarding_response_dto.dart b/mobile/openapi/lib/model/onboarding_response_dto.dart new file mode 100644 index 00000000000..033466e96b4 --- /dev/null +++ b/mobile/openapi/lib/model/onboarding_response_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class OnboardingResponseDto { + /// Returns a new [OnboardingResponseDto] instance. + OnboardingResponseDto({ + required this.isOnboarded, + }); + + bool isOnboarded; + + @override + bool operator ==(Object other) => identical(this, other) || other is OnboardingResponseDto && + other.isOnboarded == isOnboarded; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (isOnboarded.hashCode); + + @override + String toString() => 'OnboardingResponseDto[isOnboarded=$isOnboarded]'; + + Map toJson() { + final json = {}; + json[r'isOnboarded'] = this.isOnboarded; + return json; + } + + /// Returns a new [OnboardingResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static OnboardingResponseDto? fromJson(dynamic value) { + upgradeDto(value, "OnboardingResponseDto"); + if (value is Map) { + final json = value.cast(); + + return OnboardingResponseDto( + isOnboarded: mapValueOfType(json, r'isOnboarded')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = OnboardingResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = OnboardingResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of OnboardingResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = OnboardingResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'isOnboarded', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f2886f59c08..286fa47c660 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ac660808651..e3e12dc56ec 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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; diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index f1bdf160d38..6c6eae15ff6 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 2f3ae5c14bc..e94818b2b5d 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 00000000000..0028fca006e --- /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 b00b0133937..e7e40eb122a 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 4bc5f1ce0b0..a773f4a1cfc 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 a0304d51ad6..78f49fd7ae0 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 9d5ba46e120..2e613c124e1 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 75e36c1da2e..b70f02bcf5d 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 54951dfa094..4a373fc310b 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 70df619ae0b..f1b1516bbe5 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 00000000000..a37b026f130 --- /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 12f4084fbc3..00000000000 --- 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 00000000000..a4af880fb4b --- /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 de2ce7e9801..baa45779e55 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 b128a9755c5..26e8fd9c7ac 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 00000000000..d65ade1b184 --- /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 91282ae9bca..8d5800e9a8a 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 f1d8e147877..adb37d5d939 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 00000000000..4efc307932b --- /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 254db719467..ce2d8c2842a 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 7937a55f80c..fca888006ad 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 091681002ec..2978e4fd2a1 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 86c19c10a80..66cb3de2c10 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();