1
0
mirror of https://github.com/immich-app/immich.git synced 2025-08-07 23:03:36 +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

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