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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 961 additions and 235 deletions

View File

@ -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',

View File

@ -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/);
});

View File

@ -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}",

View File

@ -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)

View File

@ -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';

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].
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:
///

View File

@ -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':

View File

@ -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',

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": {
"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",

View File

@ -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;

View File

@ -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> {

View File

@ -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,
};
}

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 {
PREFERENCES = 'preferences',
LICENSE = 'license',
ONBOARDING = 'onboarding',
}
export enum UserAvatarColor {

View File

@ -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,
});

View File

@ -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();

View File

@ -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 };
}

View File

@ -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,
});

View File

@ -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>

View File

@ -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>

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 { 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>

View File

@ -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>

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
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'}

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">
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">

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';
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 }));
};

View File

@ -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) {

View File

@ -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>

View File

@ -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();