mirror of
https://github.com/immich-app/immich
synced 2025-06-09 00:09:21 +00:00
feat(web): improved user onboarding (#18782)
* wip * added user metadata key * wip * restructure onboarding system and add initial locale * update language card and fix translation updating * remove prints * new card formattings * fix cursed unmount effect * add OAuth route onboarding * remove required admin auth for onboarding * delete the hotwire button * update open-api files * delete import * fix failing oauth onboarding fields * fix e2e test * fix web e2e test * add onboarding to user registration e2e test * remove todo this was a holdover during dev and didn't get deleted * fix server small tests * use onDestroy to save settings rather than a bind:this * change to false for isOnboarded * fix other auth small test * provide type annotation in user factory metadata field * remove onboardingCompelted from UserDto * move translations to onboarding steps array and mark as derived so they update * break language selector out into its own component as per @danieldietzler suggestion * remove hello header on card * fix flixkering on server privacy card * label/id fixes * openapi --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
e7d7886f44
commit
74438f5bd8
@ -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',
|
||||
|
@ -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/);
|
||||
});
|
||||
|
@ -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}",
|
||||
|
5
mobile/openapi/README.md
generated
5
mobile/openapi/README.md
generated
@ -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)
|
||||
|
2
mobile/openapi/lib/api.dart
generated
2
mobile/openapi/lib/api.dart
generated
@ -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';
|
||||
|
121
mobile/openapi/lib/api/users_api.dart
generated
121
mobile/openapi/lib/api/users_api.dart
generated
@ -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:
|
||||
///
|
||||
|
4
mobile/openapi/lib/api_client.dart
generated
4
mobile/openapi/lib/api_client.dart
generated
@ -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':
|
||||
|
10
mobile/openapi/lib/model/login_response_dto.dart
generated
10
mobile/openapi/lib/model/login_response_dto.dart
generated
@ -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',
|
||||
|
99
mobile/openapi/lib/model/onboarding_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/onboarding_dto.dart
generated
Normal file
@ -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',
|
||||
};
|
||||
}
|
||||
|
99
mobile/openapi/lib/model/onboarding_response_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/onboarding_response_dto.dart
generated
Normal file
@ -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',
|
||||
};
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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> {
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
9
server/src/dtos/onboarding.dto.ts
Normal file
9
server/src/dtos/onboarding.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class OnboardingDto {
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
isOnboarded!: boolean;
|
||||
}
|
||||
|
||||
export class OnboardingResponseDto extends OnboardingDto {}
|
@ -211,6 +211,7 @@ export enum SystemMetadataKey {
|
||||
export enum UserMetadataKey {
|
||||
PREFERENCES = 'preferences',
|
||||
LICENSE = 'license',
|
||||
ONBOARDING = 'onboarding',
|
||||
}
|
||||
|
||||
export enum UserAvatarColor {
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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'}
|
||||
|
@ -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>
|
@ -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">
|
||||
|
4
web/src/lib/models/onboarding-role.ts
Normal file
4
web/src/lib/models/onboarding-role.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum OnboardingRole {
|
||||
SERVER = 'server',
|
||||
USER = 'user',
|
||||
}
|
@ -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 }));
|
||||
};
|
||||
|
@ -26,7 +26,6 @@
|
||||
let oauthLoading = $state(true);
|
||||
|
||||
const onSuccess = async (user: LoginResponseDto) => {
|
||||
console.log(data.continueUrl);
|
||||
await goto(data.continueUrl, { invalidateAll: true });
|
||||
eventManager.emit('auth.login', user);
|
||||
};
|
||||
@ -43,6 +42,12 @@
|
||||
if (oauth.isCallback(globalThis.location)) {
|
||||
try {
|
||||
const user = await oauth.login(globalThis.location);
|
||||
|
||||
if (!user.isOnboarded) {
|
||||
await onOnboarding();
|
||||
return;
|
||||
}
|
||||
|
||||
await onSuccess(user);
|
||||
return;
|
||||
} catch (error) {
|
||||
@ -79,10 +84,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// change the user password before we onboard them
|
||||
if (!user.isAdmin && user.shouldChangePassword) {
|
||||
await onFirstLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
// We want to onboard after the first login since their password will change
|
||||
// and handleLogin will be called again (relogin). We then do onboarding on that next call.
|
||||
if (!user.isOnboarded) {
|
||||
await onOnboarding();
|
||||
return;
|
||||
}
|
||||
|
||||
await onSuccess(user);
|
||||
return;
|
||||
} catch (error) {
|
||||
|
@ -1,17 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte';
|
||||
import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
|
||||
import OnboardingPrivacy from '$lib/components/onboarding-page/onboarding-privacy.svelte';
|
||||
import OnboardingLocale from '$lib/components/onboarding-page/onboarding-language.svelte';
|
||||
import OnboardingServerPrivacy from '$lib/components/onboarding-page/onboarding-server-privacy.svelte';
|
||||
import OnboardingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
|
||||
import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
|
||||
import OnboardingUserPrivacy from '$lib/components/onboarding-page/onboarding-user-privacy.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { retrieveServerConfig } from '$lib/stores/server-config.store';
|
||||
import { updateAdminOnboarding } from '@immich/sdk';
|
||||
|
||||
let index = $state(0);
|
||||
import { OnboardingRole } from '$lib/models/onboarding-role';
|
||||
import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk';
|
||||
import { mdiHarddisk, mdiIncognito, mdiThemeLightDark, mdiTranslate } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface OnboardingStep {
|
||||
name: string;
|
||||
@ -19,41 +23,116 @@
|
||||
| typeof OnboardingHello
|
||||
| typeof OnboardingTheme
|
||||
| typeof OnboardingStorageTemplate
|
||||
| typeof OnboardingPrivacy;
|
||||
| typeof OnboardingServerPrivacy
|
||||
| typeof OnboardingUserPrivacy
|
||||
| typeof OnboardingLocale;
|
||||
role: OnboardingRole;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
const onboardingSteps: OnboardingStep[] = [
|
||||
{ name: 'hello', component: OnboardingHello },
|
||||
{ name: 'theme', component: OnboardingTheme },
|
||||
{ name: 'privacy', component: OnboardingPrivacy },
|
||||
{ name: 'storage', component: OnboardingStorageTemplate },
|
||||
];
|
||||
const onboardingSteps: OnboardingStep[] = $derived([
|
||||
{ name: 'hello', component: OnboardingHello, role: OnboardingRole.USER },
|
||||
{
|
||||
name: 'theme',
|
||||
component: OnboardingTheme,
|
||||
role: OnboardingRole.USER,
|
||||
title: $t('theme'),
|
||||
icon: mdiThemeLightDark,
|
||||
},
|
||||
{
|
||||
name: 'language',
|
||||
component: OnboardingLocale,
|
||||
role: OnboardingRole.USER,
|
||||
title: $t('language'),
|
||||
icon: mdiTranslate,
|
||||
},
|
||||
{
|
||||
name: 'server_privacy',
|
||||
component: OnboardingServerPrivacy,
|
||||
role: OnboardingRole.SERVER,
|
||||
title: $t('server_privacy'),
|
||||
icon: mdiIncognito,
|
||||
},
|
||||
{
|
||||
name: 'user_privacy',
|
||||
component: OnboardingUserPrivacy,
|
||||
role: OnboardingRole.USER,
|
||||
title: $t('user_privacy'),
|
||||
icon: mdiIncognito,
|
||||
},
|
||||
{
|
||||
name: 'storage_template',
|
||||
component: OnboardingStorageTemplate,
|
||||
role: OnboardingRole.SERVER,
|
||||
title: $t('admin.storage_template_settings'),
|
||||
icon: mdiHarddisk,
|
||||
},
|
||||
]);
|
||||
|
||||
run(() => {
|
||||
let index = $state(0);
|
||||
let userRole = $derived($user.isAdmin && !$serverConfig.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER);
|
||||
|
||||
let onboardingStepCount = $derived(onboardingSteps.filter((step) => shouldRunStep(step.role, userRole)).length);
|
||||
let onboardingProgress = $derived(
|
||||
onboardingSteps.filter((step, i) => shouldRunStep(step.role, userRole) && i <= index).length - 1,
|
||||
);
|
||||
|
||||
const shouldRunStep = (stepRole: OnboardingRole, userRole: OnboardingRole) => {
|
||||
return (
|
||||
stepRole === OnboardingRole.USER ||
|
||||
(stepRole === OnboardingRole.SERVER && userRole === OnboardingRole.SERVER && !$serverConfig.isOnboarded)
|
||||
);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const stepState = $page.url.searchParams.get('step');
|
||||
const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
|
||||
index = temporaryIndex === -1 ? 0 : temporaryIndex;
|
||||
});
|
||||
|
||||
const handleDoneClicked = async () => {
|
||||
if (index >= onboardingSteps.length - 1) {
|
||||
await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
|
||||
await retrieveServerConfig();
|
||||
const previousStepIndex = $derived(
|
||||
onboardingSteps.findLastIndex((step, i) => shouldRunStep(step.role, userRole) && i < index),
|
||||
);
|
||||
|
||||
const nextStepIndex = $derived(
|
||||
onboardingSteps.findIndex((step, i) => shouldRunStep(step.role, userRole) && i > index),
|
||||
);
|
||||
|
||||
const handleNextClicked = async () => {
|
||||
if (nextStepIndex == -1) {
|
||||
if ($user.isAdmin) {
|
||||
await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
|
||||
await retrieveServerConfig();
|
||||
}
|
||||
|
||||
await setUserOnboarding({
|
||||
onboardingDto: { isOnboarded: true },
|
||||
});
|
||||
|
||||
await goto(AppRoute.PHOTOS);
|
||||
} else {
|
||||
index++;
|
||||
await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
|
||||
await goto(
|
||||
`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[nextStepIndex].name}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = async () => {
|
||||
if (index >= 1) {
|
||||
index--;
|
||||
await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
|
||||
if (previousStepIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
await goto(
|
||||
`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[previousStepIndex].name}`,
|
||||
);
|
||||
};
|
||||
|
||||
const SvelteComponent = $derived(onboardingSteps[index].component);
|
||||
onMount(async () => {
|
||||
await retrieveSystemConfig();
|
||||
});
|
||||
|
||||
const OnboardingStep = $derived(onboardingSteps[index].component);
|
||||
</script>
|
||||
|
||||
<section id="onboarding-page" class="min-w-dvw flex min-h-dvh p-4">
|
||||
@ -61,11 +140,20 @@
|
||||
<div class=" bg-gray-300 dark:bg-gray-600 rounded-md h-2">
|
||||
<div
|
||||
class="progress-bar bg-immich-primary dark:bg-immich-dark-primary h-2 rounded-md transition-all duration-200 ease-out"
|
||||
style="width: {(index / (onboardingSteps.length - 1)) * 100}%"
|
||||
style="width: {(onboardingProgress / onboardingStepCount) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="py-8 flex place-content-center place-items-center m-auto">
|
||||
<SvelteComponent onDone={handleDoneClicked} onPrevious={handlePrevious} />
|
||||
<OnboardingCard
|
||||
title={onboardingSteps[index].title}
|
||||
icon={onboardingSteps[index].icon}
|
||||
onNext={handleNextClicked}
|
||||
onPrevious={handlePrevious}
|
||||
previousTitle={onboardingSteps[previousStepIndex]?.title}
|
||||
nextTitle={onboardingSteps[nextStepIndex]?.title}
|
||||
>
|
||||
<OnboardingStep />
|
||||
</OnboardingCard>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -3,7 +3,7 @@ import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
await authenticate(url);
|
||||
|
||||
const $t = await getFormatter();
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user