You've already forked immich
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:
@@ -17,6 +17,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||
@@ -87,6 +88,24 @@ export class UserController {
|
||||
await this.service.deleteLicense(auth);
|
||||
}
|
||||
|
||||
@Get('me/onboarding')
|
||||
@Authenticated()
|
||||
getUserOnboarding(@Auth() auth: AuthDto): Promise<OnboardingResponseDto> {
|
||||
return this.service.getOnboarding(auth);
|
||||
}
|
||||
|
||||
@Put('me/onboarding')
|
||||
@Authenticated()
|
||||
async setUserOnboarding(@Auth() auth: AuthDto, @Body() Onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
|
||||
return this.service.setOnboarding(auth, Onboarding);
|
||||
}
|
||||
|
||||
@Delete('me/onboarding')
|
||||
@Authenticated()
|
||||
async deleteUserOnboarding(@Auth() auth: AuthDto): Promise<void> {
|
||||
await this.service.deleteOnboarding(auth);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated()
|
||||
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
|
||||
|
@@ -2,7 +2,8 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||
import { ImmichCookie } from 'src/enum';
|
||||
import { ImmichCookie, UserMetadataKey } from 'src/enum';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { Optional, PinCode, toEmail } from 'src/validation';
|
||||
|
||||
export type CookieResponse = {
|
||||
@@ -39,9 +40,14 @@ export class LoginResponseDto {
|
||||
profileImagePath!: string;
|
||||
isAdmin!: boolean;
|
||||
shouldChangePassword!: boolean;
|
||||
isOnboarded!: boolean;
|
||||
}
|
||||
|
||||
export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
|
||||
const onboardingMetadata = entity.metadata.find(
|
||||
(item): item is UserMetadataItem<UserMetadataKey.ONBOARDING> => item.key === UserMetadataKey.ONBOARDING,
|
||||
)?.value;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
userId: entity.id,
|
||||
@@ -50,6 +56,7 @@ export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginR
|
||||
isAdmin: entity.isAdmin,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
isOnboarded: onboardingMetadata?.isOnboarded ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
9
server/src/dtos/onboarding.dto.ts
Normal file
9
server/src/dtos/onboarding.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { IsBoolean, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class OnboardingDto {
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
isOnboarded!: boolean;
|
||||
}
|
||||
|
||||
export class OnboardingResponseDto extends OnboardingDto {}
|
@@ -211,6 +211,7 @@ export enum SystemMetadataKey {
|
||||
export enum UserMetadataKey {
|
||||
PREFERENCES = 'preferences',
|
||||
LICENSE = 'license',
|
||||
ONBOARDING = 'onboarding',
|
||||
}
|
||||
|
||||
export enum UserAvatarColor {
|
||||
|
@@ -28,6 +28,7 @@ const oauthResponse = ({
|
||||
name,
|
||||
profileImagePath,
|
||||
isAdmin: false,
|
||||
isOnboarded: false,
|
||||
shouldChangePassword: false,
|
||||
});
|
||||
|
||||
@@ -101,6 +102,7 @@ describe(AuthService.name, () => {
|
||||
name: user.name,
|
||||
profileImagePath: user.profileImagePath,
|
||||
isAdmin: user.isAdmin,
|
||||
isOnboarded: false,
|
||||
shouldChangePassword: user.shouldChangePassword,
|
||||
});
|
||||
|
||||
|
@@ -6,6 +6,7 @@ import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
@@ -179,6 +180,39 @@ export class UserService extends BaseService {
|
||||
return { ...license, activatedAt };
|
||||
}
|
||||
|
||||
async getOnboarding(auth: AuthDto): Promise<OnboardingResponseDto> {
|
||||
const metadata = await this.userRepository.getMetadata(auth.user.id);
|
||||
|
||||
const onboardingData = metadata.find(
|
||||
(item): item is UserMetadataItem<UserMetadataKey.ONBOARDING> => item.key === UserMetadataKey.ONBOARDING,
|
||||
)?.value;
|
||||
|
||||
if (!onboardingData) {
|
||||
return { isOnboarded: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isOnboarded: onboardingData.isOnboarded,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteOnboarding({ user }: AuthDto): Promise<void> {
|
||||
await this.userRepository.deleteMetadata(user.id, UserMetadataKey.ONBOARDING);
|
||||
}
|
||||
|
||||
async setOnboarding(auth: AuthDto, onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
|
||||
await this.userRepository.upsertMetadata(auth.user.id, {
|
||||
key: UserMetadataKey.ONBOARDING,
|
||||
value: {
|
||||
isOnboarded: onboarding.isOnboarded,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isOnboarded: onboarding.isOnboarded,
|
||||
};
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK })
|
||||
async handleUserSyncUsage(): Promise<JobStatus> {
|
||||
await this.userRepository.syncUsage();
|
||||
|
@@ -510,4 +510,5 @@ export interface UserPreferences {
|
||||
export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {
|
||||
[UserMetadataKey.PREFERENCES]: DeepPartial<UserPreferences>;
|
||||
[UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string };
|
||||
[UserMetadataKey.ONBOARDING]: { isOnboarded: boolean };
|
||||
}
|
||||
|
@@ -15,8 +15,8 @@ import {
|
||||
} from 'src/database';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserStatus } from 'src/enum';
|
||||
import { OnThisDayData } from 'src/types';
|
||||
import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserMetadataKey, UserStatus } from 'src/enum';
|
||||
import { OnThisDayData, UserMetadataItem } from 'src/types';
|
||||
|
||||
export const newUuid = () => randomUUID() as string;
|
||||
export const newUuids = () =>
|
||||
@@ -146,6 +146,12 @@ const userFactory = (user: Partial<User> = {}) => ({
|
||||
avatarColor: null,
|
||||
profileImagePath: '',
|
||||
profileChangedAt: newDate(),
|
||||
metadata: [
|
||||
{
|
||||
key: UserMetadataKey.ONBOARDING,
|
||||
value: 'true',
|
||||
},
|
||||
] as UserMetadataItem[],
|
||||
...user,
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user