1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-04 05:50:38 +02: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:
Brandon Wees
2025-06-02 16:09:13 -05:00
committed by GitHub
parent e7d7886f44
commit 74438f5bd8
36 changed files with 961 additions and 235 deletions

View File

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

View File

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

View File

@ -1294,9 +1294,11 @@
"oldest_first": "Oldest first", "oldest_first": "Oldest first",
"on_this_device": "On this device", "on_this_device": "On this device",
"onboarding": "Onboarding", "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_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}", "onboarding_welcome_user": "Welcome, {user}",
"online": "Online", "online": "Online",
"only_favorites": "Only favorites", "only_favorites": "Only favorites",
@ -1608,6 +1610,7 @@
"server_info_box_server_url": "Server URL", "server_info_box_server_url": "Server URL",
"server_offline": "Server Offline", "server_offline": "Server Offline",
"server_online": "Server Online", "server_online": "Server Online",
"server_privacy": "Server Privacy",
"server_stats": "Server Stats", "server_stats": "Server Stats",
"server_version": "Server Version", "server_version": "Server Version",
"set": "Set", "set": "Set",
@ -1879,6 +1882,7 @@
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", "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": "PIN Code",
"user_pin_code_settings_description": "Manage your PIN code", "user_pin_code_settings_description": "Manage your PIN code",
"user_privacy": "User Privacy",
"user_purchase_settings": "Purchase", "user_purchase_settings": "Purchase",
"user_purchase_settings_description": "Manage your purchase", "user_purchase_settings_description": "Manage your purchase",
"user_role_set": "Set {user} as {role}", "user_role_set": "Set {user} as {role}",

View File

@ -247,13 +247,16 @@ Class | Method | HTTP request | Description
*UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image | *UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image |
*UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | *UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image |
*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | *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* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences |
*UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | *UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me |
*UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | *UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image |
*UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} | *UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} |
*UsersApi* | [**getUserLicense**](doc//UsersApi.md#getuserlicense) | **GET** /users/me/license | *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* | [**searchUsers**](doc//UsersApi.md#searchusers) | **GET** /users |
*UsersApi* | [**setUserLicense**](doc//UsersApi.md#setuserlicense) | **PUT** /users/me/license | *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* | [**updateMyPreferences**](doc//UsersApi.md#updatemypreferences) | **PUT** /users/me/preferences |
*UsersApi* | [**updateMyUser**](doc//UsersApi.md#updatemyuser) | **PUT** /users/me | *UsersApi* | [**updateMyUser**](doc//UsersApi.md#updatemyuser) | **PUT** /users/me |
*UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users | *UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users |
@ -385,6 +388,8 @@ Class | Method | HTTP request | Description
- [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md)
- [OAuthTokenEndpointAuthMethod](doc//OAuthTokenEndpointAuthMethod.md) - [OAuthTokenEndpointAuthMethod](doc//OAuthTokenEndpointAuthMethod.md)
- [OnThisDayDto](doc//OnThisDayDto.md) - [OnThisDayDto](doc//OnThisDayDto.md)
- [OnboardingDto](doc//OnboardingDto.md)
- [OnboardingResponseDto](doc//OnboardingResponseDto.md)
- [PartnerDirection](doc//PartnerDirection.md) - [PartnerDirection](doc//PartnerDirection.md)
- [PartnerResponseDto](doc//PartnerResponseDto.md) - [PartnerResponseDto](doc//PartnerResponseDto.md)
- [PeopleResponse](doc//PeopleResponse.md) - [PeopleResponse](doc//PeopleResponse.md)

View File

@ -177,6 +177,8 @@ part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart'; part 'model/o_auth_config_dto.dart';
part 'model/o_auth_token_endpoint_auth_method.dart'; part 'model/o_auth_token_endpoint_auth_method.dart';
part 'model/on_this_day_dto.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_direction.dart';
part 'model/partner_response_dto.dart'; part 'model/partner_response_dto.dart';
part 'model/people_response.dart'; part 'model/people_response.dart';

View File

@ -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]. /// Performs an HTTP 'GET /users/me/preferences' operation and returns the [Response].
Future<Response> getMyPreferencesWithHttpInfo() async { Future<Response> getMyPreferencesWithHttpInfo() async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
@ -358,6 +391,47 @@ class UsersApi {
return null; 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]. /// Performs an HTTP 'GET /users' operation and returns the [Response].
Future<Response> searchUsersWithHttpInfo() async { Future<Response> searchUsersWithHttpInfo() async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
@ -449,6 +523,53 @@ class UsersApi {
return null; 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]. /// Performs an HTTP 'PUT /users/me/preferences' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

@ -410,6 +410,10 @@ class ApiClient {
return OAuthTokenEndpointAuthMethodTypeTransformer().decode(value); return OAuthTokenEndpointAuthMethodTypeTransformer().decode(value);
case 'OnThisDayDto': case 'OnThisDayDto':
return OnThisDayDto.fromJson(value); return OnThisDayDto.fromJson(value);
case 'OnboardingDto':
return OnboardingDto.fromJson(value);
case 'OnboardingResponseDto':
return OnboardingResponseDto.fromJson(value);
case 'PartnerDirection': case 'PartnerDirection':
return PartnerDirectionTypeTransformer().decode(value); return PartnerDirectionTypeTransformer().decode(value);
case 'PartnerResponseDto': case 'PartnerResponseDto':

View File

@ -15,6 +15,7 @@ class LoginResponseDto {
LoginResponseDto({ LoginResponseDto({
required this.accessToken, required this.accessToken,
required this.isAdmin, required this.isAdmin,
required this.isOnboarded,
required this.name, required this.name,
required this.profileImagePath, required this.profileImagePath,
required this.shouldChangePassword, required this.shouldChangePassword,
@ -26,6 +27,8 @@ class LoginResponseDto {
bool isAdmin; bool isAdmin;
bool isOnboarded;
String name; String name;
String profileImagePath; String profileImagePath;
@ -40,6 +43,7 @@ class LoginResponseDto {
bool operator ==(Object other) => identical(this, other) || other is LoginResponseDto && bool operator ==(Object other) => identical(this, other) || other is LoginResponseDto &&
other.accessToken == accessToken && other.accessToken == accessToken &&
other.isAdmin == isAdmin && other.isAdmin == isAdmin &&
other.isOnboarded == isOnboarded &&
other.name == name && other.name == name &&
other.profileImagePath == profileImagePath && other.profileImagePath == profileImagePath &&
other.shouldChangePassword == shouldChangePassword && other.shouldChangePassword == shouldChangePassword &&
@ -51,6 +55,7 @@ class LoginResponseDto {
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(accessToken.hashCode) + (accessToken.hashCode) +
(isAdmin.hashCode) + (isAdmin.hashCode) +
(isOnboarded.hashCode) +
(name.hashCode) + (name.hashCode) +
(profileImagePath.hashCode) + (profileImagePath.hashCode) +
(shouldChangePassword.hashCode) + (shouldChangePassword.hashCode) +
@ -58,12 +63,13 @@ class LoginResponseDto {
(userId.hashCode); (userId.hashCode);
@override @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() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'accessToken'] = this.accessToken; json[r'accessToken'] = this.accessToken;
json[r'isAdmin'] = this.isAdmin; json[r'isAdmin'] = this.isAdmin;
json[r'isOnboarded'] = this.isOnboarded;
json[r'name'] = this.name; json[r'name'] = this.name;
json[r'profileImagePath'] = this.profileImagePath; json[r'profileImagePath'] = this.profileImagePath;
json[r'shouldChangePassword'] = this.shouldChangePassword; json[r'shouldChangePassword'] = this.shouldChangePassword;
@ -83,6 +89,7 @@ class LoginResponseDto {
return LoginResponseDto( return LoginResponseDto(
accessToken: mapValueOfType<String>(json, r'accessToken')!, accessToken: mapValueOfType<String>(json, r'accessToken')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!, isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!, profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!, shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
@ -137,6 +144,7 @@ class LoginResponseDto {
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'accessToken', 'accessToken',
'isAdmin', 'isAdmin',
'isOnboarded',
'name', 'name',
'profileImagePath', 'profileImagePath',
'shouldChangePassword', 'shouldChangePassword',

View 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',
};
}

View 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',
};
}

View File

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

View File

@ -512,6 +512,7 @@ export type LoginCredentialDto = {
export type LoginResponseDto = { export type LoginResponseDto = {
accessToken: string; accessToken: string;
isAdmin: boolean; isAdmin: boolean;
isOnboarded: boolean;
name: string; name: string;
profileImagePath: string; profileImagePath: string;
shouldChangePassword: boolean; shouldChangePassword: boolean;
@ -1470,6 +1471,12 @@ export type UserUpdateMeDto = {
name?: string; name?: string;
password?: string; password?: string;
}; };
export type OnboardingResponseDto = {
isOnboarded: boolean;
};
export type OnboardingDto = {
isOnboarded: boolean;
};
export type CreateProfileImageDto = { export type CreateProfileImageDto = {
file: Blob; file: Blob;
}; };
@ -3582,6 +3589,32 @@ export function setUserLicense({ licenseKeyDto }: {
body: 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) { export function getMyPreferences(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;

View File

@ -17,6 +17,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from 'express';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.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 { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
@ -87,6 +88,24 @@ export class UserController {
await this.service.deleteLicense(auth); 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') @Get(':id')
@Authenticated() @Authenticated()
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> { getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.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 { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
@ -179,6 +180,39 @@ export class UserService extends BaseService {
return { ...license, activatedAt }; 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 }) @OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK })
async handleUserSyncUsage(): Promise<JobStatus> { async handleUserSyncUsage(): Promise<JobStatus> {
await this.userRepository.syncUsage(); await this.userRepository.syncUsage();

View File

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

View File

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

View File

@ -1,15 +1,32 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; 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 type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
interface Props { interface Props {
title?: string | undefined; title?: string | undefined;
icon?: string | undefined; icon?: string | undefined;
children?: Snippet; 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> </script>
<div <div
@ -30,4 +47,37 @@
</div> </div>
{/if} {/if}
{@render children?.()} {@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> </div>

View File

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

View File

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

View File

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

View File

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

View File

@ -5,18 +5,7 @@
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { getConfig, type SystemConfigDto } from '@immich/sdk'; import { getConfig, type SystemConfigDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiArrowLeft, mdiCheck, mdiHarddisk } from '@mdi/js';
import { onMount } from 'svelte'; 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 config: SystemConfigDto | undefined = $state();
let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>(); let adminSettingsComponent = $state<ReturnType<typeof AdminSettings>>();
@ -24,9 +13,13 @@
onMount(async () => { onMount(async () => {
config = await getConfig(); config = await getConfig();
}); });
export const save = async () => {
await adminSettingsComponent?.handleSave({ storageTemplate: config?.storageTemplate });
};
</script> </script>
<OnboardingCard title={$t('admin.storage_template_settings')} icon={mdiHarddisk}> <div class="flex flex-col">
<p> <p>
<FormatMessage key="admin.storage_template_onboarding_description"> <FormatMessage key="admin.storage_template_onboarding_description">
{#snippet children({ message })} {#snippet children({ message })}
@ -48,36 +41,9 @@
onSave={(config) => adminSettingsComponent?.handleSave(config)} onSave={(config) => adminSettingsComponent?.handleSave(config)}
onReset={(options) => adminSettingsComponent?.handleReset(options)} onReset={(options) => adminSettingsComponent?.handleReset(options)}
duration={0} 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} {/if}
{/snippet} {/snippet}
</AdminSettings> </AdminSettings>
{/if} {/if}
</OnboardingCard> </div>

View File

@ -3,27 +3,16 @@
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { Theme } from '$lib/constants'; import { Theme } from '$lib/constants';
import { themeManager } from '$lib/managers/theme-manager.svelte'; 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 { t } from 'svelte-i18n';
import OnboardingCard from './onboarding-card.svelte';
interface Props {
onDone: () => void;
}
let { onDone }: Props = $props();
</script> </script>
<OnboardingCard icon={mdiThemeLightDark} title={$t('color_theme')}> <div class="flex flex-col gap-4">
<div> <p>{$t('onboarding_theme_description')}</p>
<p class="pb-6 font-light">{$t('onboarding_theme_description')}</p>
</div>
<div class="flex gap-4 mb-6"> <div class="flex gap-4">
<button <button
type="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)} onclick={() => themeManager.setTheme(Theme.LIGHT)}
> >
<div <div
@ -35,7 +24,7 @@
</button> </button>
<button <button
type="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)} onclick={() => themeManager.setTheme(Theme.DARK)}
> >
<div <div
@ -46,17 +35,4 @@
</div> </div>
</button> </button>
</div> </div>
</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>

View File

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

View File

@ -348,7 +348,7 @@
<ul <ul
role="listbox" role="listbox"
id={listboxId} 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="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-b-xl={dropdownDirection === 'bottom'}
class:rounded-t-xl={dropdownDirection === 'top'} class:rounded-t-xl={dropdownDirection === 'top'}

View File

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

View File

@ -1,22 +1,20 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation';
import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import SettingCombobox from '$lib/components/shared-components/settings/setting-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 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 { themeManager } from '$lib/managers/theme-manager.svelte';
import { import {
alwaysLoadOriginalFile, alwaysLoadOriginalFile,
lang,
locale, locale,
loopVideo, loopVideo,
playVideoThumbnailOnHover, playVideoThumbnailOnHover,
showDeleteModal, showDeleteModal,
} from '$lib/stores/preferences.store'; } from '$lib/stores/preferences.store';
import { findLocale } from '$lib/utils'; import { findLocale } from '$lib/utils';
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { locale as i18nLocale, t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
let time = $state(new Date()); let time = $state(new Date());
@ -44,24 +42,6 @@
$locale = $locale ? undefined : fallbackLocale.code; $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) => { const handleLocaleChange = (newLocale: string | undefined) => {
if (newLocale) { if (newLocale) {
$locale = newLocale; $locale = newLocale;
@ -87,7 +67,6 @@
value: findLocale(editedLocale).code || fallbackLocale.code, value: findLocale(editedLocale).code || fallbackLocale.code,
label: findLocale(editedLocale).name || fallbackLocale.name, label: findLocale(editedLocale).name || fallbackLocale.name,
}); });
let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
</script> </script>
<section class="my-4"> <section class="my-4">
@ -103,14 +82,7 @@
</div> </div>
<div class="ms-4"> <div class="ms-4">
<SettingCombobox <SettingsLanguageSelector showSettingDescription />
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)}
/>
</div> </div>
<div class="ms-4"> <div class="ms-4">

View File

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

View File

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

View File

@ -26,7 +26,6 @@
let oauthLoading = $state(true); let oauthLoading = $state(true);
const onSuccess = async (user: LoginResponseDto) => { const onSuccess = async (user: LoginResponseDto) => {
console.log(data.continueUrl);
await goto(data.continueUrl, { invalidateAll: true }); await goto(data.continueUrl, { invalidateAll: true });
eventManager.emit('auth.login', user); eventManager.emit('auth.login', user);
}; };
@ -43,6 +42,12 @@
if (oauth.isCallback(globalThis.location)) { if (oauth.isCallback(globalThis.location)) {
try { try {
const user = await oauth.login(globalThis.location); const user = await oauth.login(globalThis.location);
if (!user.isOnboarded) {
await onOnboarding();
return;
}
await onSuccess(user); await onSuccess(user);
return; return;
} catch (error) { } catch (error) {
@ -79,10 +84,19 @@
return; return;
} }
// change the user password before we onboard them
if (!user.isAdmin && user.shouldChangePassword) { if (!user.isAdmin && user.shouldChangePassword) {
await onFirstLogin(); await onFirstLogin();
return; 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); await onSuccess(user);
return; return;
} catch (error) { } catch (error) {

View File

@ -1,17 +1,21 @@
<script lang="ts"> <script lang="ts">
import { run } from 'svelte/legacy';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; 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 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 OnboardingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.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 { AppRoute, QueryParameter } from '$lib/constants';
import { retrieveServerConfig } from '$lib/stores/server-config.store'; import { OnboardingRole } from '$lib/models/onboarding-role';
import { updateAdminOnboarding } from '@immich/sdk'; import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
let index = $state(0); 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 { interface OnboardingStep {
name: string; name: string;
@ -19,41 +23,116 @@
| typeof OnboardingHello | typeof OnboardingHello
| typeof OnboardingTheme | typeof OnboardingTheme
| typeof OnboardingStorageTemplate | typeof OnboardingStorageTemplate
| typeof OnboardingPrivacy; | typeof OnboardingServerPrivacy
| typeof OnboardingUserPrivacy
| typeof OnboardingLocale;
role: OnboardingRole;
title?: string;
icon?: string;
} }
const onboardingSteps: OnboardingStep[] = [ const onboardingSteps: OnboardingStep[] = $derived([
{ name: 'hello', component: OnboardingHello }, { name: 'hello', component: OnboardingHello, role: OnboardingRole.USER },
{ name: 'theme', component: OnboardingTheme }, {
{ name: 'privacy', component: OnboardingPrivacy }, name: 'theme',
{ name: 'storage', component: OnboardingStorageTemplate }, 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 stepState = $page.url.searchParams.get('step');
const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState); const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
index = temporaryIndex === -1 ? 0 : temporaryIndex; index = temporaryIndex === -1 ? 0 : temporaryIndex;
}); });
const handleDoneClicked = async () => { const previousStepIndex = $derived(
if (index >= onboardingSteps.length - 1) { onboardingSteps.findLastIndex((step, i) => shouldRunStep(step.role, userRole) && i < index),
await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } }); );
await retrieveServerConfig();
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); await goto(AppRoute.PHOTOS);
} else { } else {
index++; await goto(
await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`); `${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[nextStepIndex].name}`,
);
} }
}; };
const handlePrevious = async () => { const handlePrevious = async () => {
if (index >= 1) { if (previousStepIndex === -1) {
index--; return;
await goto(`${AppRoute.AUTH_ONBOARDING}?${QueryParameter.ONBOARDING_STEP}=${onboardingSteps[index].name}`);
} }
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> </script>
<section id="onboarding-page" class="min-w-dvw flex min-h-dvh p-4"> <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=" bg-gray-300 dark:bg-gray-600 rounded-md h-2">
<div <div
class="progress-bar bg-immich-primary dark:bg-immich-dark-primary h-2 rounded-md transition-all duration-200 ease-out" 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> </div>
<div class="py-8 flex place-content-center place-items-center m-auto"> <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>
</div> </div>
</section> </section>

View File

@ -3,7 +3,7 @@ import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ url }) => { export const load = (async ({ url }) => {
await authenticate(url, { admin: true }); await authenticate(url);
const $t = await getFormatter(); const $t = await getFormatter();