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

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