diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 0148f2e1e9..bb6d17a248 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 74bee64e0a..0fde9a6ec6 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 3e37b2da65..98ca467c51 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 73bbe7c1ff..4ff55e5db8 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 846db953dc..87d14248eb 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 a48ec54cfe..cd31617e74 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<Response> deleteUserOnboardingWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/users/me/onboarding'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = <QueryParam>[]; + final headerParams = <String, String>{}; + final formParams = <String, String>{}; + + const contentTypes = <String>[]; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future<void> 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<Response> 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<Response> getUserOnboardingWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/users/me/onboarding'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = <QueryParam>[]; + final headerParams = <String, String>{}; + final formParams = <String, String>{}; + + const contentTypes = <String>[]; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future<OnboardingResponseDto?> 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<Response> 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<Response> setUserOnboardingWithHttpInfo(OnboardingDto onboardingDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/users/me/onboarding'; + + // ignore: prefer_final_locals + Object? postBody = onboardingDto; + + final queryParams = <QueryParam>[]; + final headerParams = <String, String>{}; + final formParams = <String, String>{}; + + const contentTypes = <String>['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [OnboardingDto] onboardingDto (required): + Future<OnboardingResponseDto?> 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 2657cece1c..46936fa88b 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 dbc82d07ba..82a4f9b3ed 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<String, dynamic> toJson() { final json = <String, dynamic>{}; 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<String>(json, r'accessToken')!, isAdmin: mapValueOfType<bool>(json, r'isAdmin')!, + isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!, name: mapValueOfType<String>(json, r'name')!, profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!, shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!, @@ -137,6 +144,7 @@ class LoginResponseDto { static const requiredKeys = <String>{ '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 0000000000..670b6a5c68 --- /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<String, dynamic> toJson() { + final json = <String, dynamic>{}; + 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<String, dynamic>(); + + return OnboardingDto( + isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!, + ); + } + return null; + } + + static List<OnboardingDto> listFromJson(dynamic json, {bool growable = false,}) { + final result = <OnboardingDto>[]; + 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<String, OnboardingDto> mapFromJson(dynamic json) { + final map = <String, OnboardingDto>{}; + if (json is Map && json.isNotEmpty) { + json = json.cast<String, dynamic>(); // 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<String, List<OnboardingDto>> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = <String, List<OnboardingDto>>{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast<String, dynamic>(); + 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 = <String>{ + '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 0000000000..033466e96b --- /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<String, dynamic> toJson() { + final json = <String, dynamic>{}; + 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<String, dynamic>(); + + return OnboardingResponseDto( + isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!, + ); + } + return null; + } + + static List<OnboardingResponseDto> listFromJson(dynamic json, {bool growable = false,}) { + final result = <OnboardingResponseDto>[]; + 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<String, OnboardingResponseDto> mapFromJson(dynamic json) { + final map = <String, OnboardingResponseDto>{}; + if (json is Map && json.isNotEmpty) { + json = json.cast<String, dynamic>(); // 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<String, List<OnboardingResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = <String, List<OnboardingResponseDto>>{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast<String, dynamic>(); + 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 = <String>{ + 'isOnboarded', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f2886f59c0..286fa47c66 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 ac66080865..e3e12dc56e 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 f1bdf160d3..6c6eae15ff 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<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> { diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 2f3ae5c14b..e94818b2b5 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<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, }; } diff --git a/server/src/dtos/onboarding.dto.ts b/server/src/dtos/onboarding.dto.ts new file mode 100644 index 0000000000..0028fca006 --- /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 b00b013393..e7e40eb122 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 4bc5f1ce0b..a773f4a1cf 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 a0304d51ad..78f49fd7ae 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<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(); diff --git a/server/src/types.ts b/server/src/types.ts index 9d5ba46e12..2e613c124e 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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 }; } diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 75e36c1da2..b70f02bcf5 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<User> = {}) => ({ 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 54951dfa09..4a373fc310 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 @@ <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> diff --git a/web/src/lib/components/onboarding-page/onboarding-hello.svelte b/web/src/lib/components/onboarding-page/onboarding-hello.svelte index 70df619ae0..f1b1516bbe 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 @@ <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> 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 0000000000..a37b026f13 --- /dev/null +++ b/web/src/lib/components/onboarding-page/onboarding-language.svelte @@ -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> 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 12f4084fbc..0000000000 --- a/web/src/lib/components/onboarding-page/onboarding-privacy.svelte +++ /dev/null @@ -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> 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 0000000000..a4af880fb4 --- /dev/null +++ b/web/src/lib/components/onboarding-page/onboarding-server-privacy.svelte @@ -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> 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 de2ce7e980..baa45779e5 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<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> diff --git a/web/src/lib/components/onboarding-page/onboarding-theme.svelte b/web/src/lib/components/onboarding-page/onboarding-theme.svelte index b128a9755c..26e8fd9c7a 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(); </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> 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 0000000000..d65ade1b18 --- /dev/null +++ b/web/src/lib/components/onboarding-page/onboarding-user-privacy.svelte @@ -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> diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 91282ae9bc..8d5800e9a8 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -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'} diff --git a/web/src/lib/components/shared-components/settings/settings-language-selector.svelte b/web/src/lib/components/shared-components/settings/settings-language-selector.svelte new file mode 100644 index 0000000000..7e395748a0 --- /dev/null +++ b/web/src/lib/components/shared-components/settings/settings-language-selector.svelte @@ -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> 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 f1d8e14787..adb37d5d93 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 @@ <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"> diff --git a/web/src/lib/models/onboarding-role.ts b/web/src/lib/models/onboarding-role.ts new file mode 100644 index 0000000000..4efc307932 --- /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 254db71946..ce2d8c2842 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<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 })); +}; diff --git a/web/src/routes/auth/login/+page.svelte b/web/src/routes/auth/login/+page.svelte index 7937a55f80..fca888006a 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 091681002e..2978e4fd2a 100644 --- a/web/src/routes/auth/onboarding/+page.svelte +++ b/web/src/routes/auth/onboarding/+page.svelte @@ -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> diff --git a/web/src/routes/auth/onboarding/+page.ts b/web/src/routes/auth/onboarding/+page.ts index 86c19c10a8..66cb3de2c1 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();