1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-16 00:36:47 +01:00

feat(server): user preferences (#9736)

* refactor(server): user endpoints

* feat(server): user preferences

* mobile: user preference

* wording

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2024-05-27 22:16:53 -04:00 committed by GitHub
parent 1f9158c545
commit 0fc6d69824
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 532 additions and 221 deletions

View file

@ -77,7 +77,6 @@ immich-admin list-users
deletedAt: null,
updatedAt: 2023-09-21T15:42:28.129Z,
oauthId: '',
memoriesEnabled: true
}
]
```

View file

@ -1,4 +1,11 @@
import { LoginResponseDto, deleteUserAdmin, getMyUser, getUserAdmin, login } from '@immich/sdk';
import {
LoginResponseDto,
deleteUserAdmin,
getMyUser,
getUserAdmin,
getUserPreferencesAdmin,
login,
} from '@immich/sdk';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
@ -103,15 +110,7 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden);
});
for (const key of [
'password',
'email',
'name',
'quotaSizeInBytes',
'shouldChangePassword',
'memoriesEnabled',
'notify',
]) {
for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.post(`/admin/users`)
@ -139,23 +138,6 @@ describe('/admin/users', () => {
});
expect(status).toBe(201);
});
it('should create a user without memories enabled', async () => {
const { status, body } = await request(app)
.post(`/admin/users`)
.send({
email: 'no-memories@immich.cloud',
password: 'Password123',
name: 'No Memories',
memoriesEnabled: false,
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({
email: 'no-memories@immich.cloud',
memoriesEnabled: false,
});
expect(status).toBe(201);
});
});
describe('PUT /admin/users/:id', () => {
@ -173,7 +155,7 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden);
});
for (const key of ['password', 'email', 'name', 'shouldChangePassword', 'memoriesEnabled']) {
for (const key of ['password', 'email', 'name', 'shouldChangePassword']) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.put(`/admin/users/${uuidDto.notFound}`)
@ -221,22 +203,6 @@ describe('/admin/users', () => {
expect(before.updatedAt).not.toEqual(body.updatedAt);
});
it('should update memories enabled', async () => {
const before = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}`)
.send({ memoriesEnabled: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
...before,
updatedAt: expect.anything(),
memoriesEnabled: false,
});
expect(before.updatedAt).not.toEqual(body.updatedAt);
});
it('should update password', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${nonAdmin.userId}`)
@ -254,6 +220,43 @@ describe('/admin/users', () => {
});
});
describe('PUT /admin/users/:id/preferences', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/admin/users/${userToDelete.userId}/preferences`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should update memories enabled', async () => {
const before = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ memories: { enabled: true } });
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}/preferences`)
.send({ memories: { enabled: false } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ memories: { enabled: false } });
const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ memories: { enabled: false } });
});
it('should update the avatar color', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}/preferences`)
.send({ avatar: { color: 'orange' } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } });
const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toEqual({ avatar: { color: 'orange' }, memories: { enabled: false } });
});
});
describe('DELETE /admin/users/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`);

View file

@ -1,4 +1,4 @@
import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyUser, login } from '@immich/sdk';
import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyPreferences, getMyUser, login } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
@ -69,7 +69,6 @@ describe('/users', () => {
expect(body).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
memoriesEnabled: true,
quotaUsageInBytes: 0,
});
});
@ -82,7 +81,7 @@ describe('/users', () => {
expect(body).toEqual(errorDto.unauthorized);
});
for (const key of ['email', 'name', 'memoriesEnabled', 'avatarColor']) {
for (const key of ['email', 'name']) {
it(`should not allow null ${key}`, async () => {
const dto = { [key]: null };
const { status, body } = await request(app)
@ -110,24 +109,6 @@ describe('/users', () => {
});
});
it('should update memories enabled', async () => {
const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/users/me`)
.send({ memoriesEnabled: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
...before,
updatedAt: expect.anything(),
memoriesEnabled: false,
});
const after = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
expect(after.memoriesEnabled).toBe(false);
});
/** @deprecated */
it('should allow a user to change their password (deprecated)', async () => {
const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
@ -176,6 +157,24 @@ describe('/users', () => {
});
});
describe('PUT /users/me/preferences', () => {
it('should update memories enabled', async () => {
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(before).toMatchObject({ memories: { enabled: true } });
const { status, body } = await request(app)
.put(`/users/me/preferences`)
.send({ memories: { enabled: false } })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ memories: { enabled: false } });
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
expect(after).toMatchObject({ memories: { enabled: false } });
});
});
describe('GET /users/:id', () => {
it('should require authentication', async () => {
const { status } = await request(app).get(`/users/${admin.userId}`);
@ -194,7 +193,6 @@ describe('/users', () => {
expect(body).not.toMatchObject({
shouldChangePassword: expect.anything(),
memoriesEnabled: expect.anything(),
storageLabel: expect.anything(),
});
});

View file

@ -1,5 +1,3 @@
import { UserAvatarColor } from '@immich/sdk';
export const uuidDto = {
invalid: 'invalid-uuid',
// valid uuid v4
@ -70,8 +68,6 @@ export const userDto = {
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.Primary,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
@ -88,8 +84,6 @@ export const userDto = {
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.Primary,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},

View file

@ -68,7 +68,6 @@ export const signupResponseDto = {
updatedAt: expect.any(String),
deletedAt: null,
oauthId: '',
memoriesEnabled: true,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: 'active',

View file

@ -27,8 +27,10 @@ class User {
Id get isarId => fastHash(id);
User.fromUserDto(UserAdminResponseDto dto)
: id = dto.id,
User.fromUserDto(
UserAdminResponseDto dto,
UserPreferencesResponseDto? preferences,
) : id = dto.id,
updatedAt = dto.updatedAt,
email = dto.email,
name = dto.name,
@ -36,7 +38,7 @@ class User {
isPartnerSharedWith = false,
profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin,
memoryEnabled = dto.memoriesEnabled ?? false,
memoryEnabled = preferences?.memories.enabled ?? false,
avatarColor = dto.avatarColor.toAvatarColor(),
inTimeline = false,
quotaUsageInBytes = dto.quotaUsageInBytes ?? 0,

View file

@ -177,8 +177,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
retResult = false;
} else {
UserAdminResponseDto? userResponseDto;
UserPreferencesResponseDto? userPreferences;
try {
userResponseDto = await _apiService.userApi.getMyUser();
userPreferences = await _apiService.userApi.getMyPreferences();
} on ApiException catch (error, stackTrace) {
_log.severe(
"Error getting user information from the server [API EXCEPTION]",
@ -201,13 +203,13 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
Store.put(
StoreKey.currentUser,
User.fromUserDto(userResponseDto),
User.fromUserDto(userResponseDto, userPreferences),
);
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
shouldChangePassword = userResponseDto.shouldChangePassword;
user = User.fromUserDto(userResponseDto);
user = User.fromUserDto(userResponseDto, userPreferences);
retResult = true;
} else {

View file

@ -21,10 +21,11 @@ class CurrentUserProvider extends StateNotifier<User?> {
refresh() async {
try {
final user = await _apiService.userApi.getMyUser();
final userPreferences = await _apiService.userApi.getMyPreferences();
if (user != null) {
Store.put(
StoreKey.currentUser,
User.fromUserDto(user),
User.fromUserDto(user, userPreferences),
);
}
} catch (_) {}

View file

@ -58,6 +58,8 @@ class TabNavigationObserver extends AutoRouterObserver {
try {
final userResponseDto =
await ref.read(apiServiceProvider).userApi.getMyUser();
final userPreferences =
await ref.read(apiServiceProvider).userApi.getMyPreferences();
if (userResponseDto == null) {
return;
@ -65,7 +67,7 @@ class TabNavigationObserver extends AutoRouterObserver {
Store.put(
StoreKey.currentUser,
User.fromUserDto(userResponseDto),
User.fromUserDto(userResponseDto, userPreferences),
);
ref.read(serverInfoProvider.notifier).getServerVersion();
} catch (e) {

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -432,6 +432,98 @@
]
}
},
"/admin/users/{id}/preferences": {
"get": {
"operationId": "getUserPreferencesAdmin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPreferencesResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
},
"put": {
"operationId": "updateUserPreferencesAdmin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPreferencesUpdateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPreferencesResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
}
},
"/admin/users/{id}/restore": {
"post": {
"operationId": "restoreUserAdmin",
@ -6403,6 +6495,78 @@
]
}
},
"/users/me/preferences": {
"get": {
"operationId": "getMyPreferences",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPreferencesResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
},
"put": {
"operationId": "updateMyPreferences",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPreferencesUpdateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserPreferencesResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
}
},
"/users/profile-image": {
"delete": {
"operationId": "deleteProfileImage",
@ -7621,6 +7785,25 @@
],
"type": "object"
},
"AvatarResponse": {
"properties": {
"color": {
"$ref": "#/components/schemas/UserAvatarColor"
}
},
"required": [
"color"
],
"type": "object"
},
"AvatarUpdate": {
"properties": {
"color": {
"$ref": "#/components/schemas/UserAvatarColor"
}
},
"type": "object"
},
"BulkIdResponseDto": {
"properties": {
"error": {
@ -8584,6 +8767,17 @@
],
"type": "object"
},
"MemoryResponse": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"MemoryResponseDto": {
"properties": {
"assets": {
@ -8650,6 +8844,14 @@
],
"type": "string"
},
"MemoryUpdate": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"MemoryUpdateDto": {
"properties": {
"isSaved": {
@ -10878,9 +11080,6 @@
"email": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
@ -10942,9 +11141,6 @@
"isAdmin": {
"type": "boolean"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
@ -11000,15 +11196,9 @@
},
"UserAdminUpdateDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
@ -11046,6 +11236,32 @@
],
"type": "string"
},
"UserPreferencesResponseDto": {
"properties": {
"avatar": {
"$ref": "#/components/schemas/AvatarResponse"
},
"memories": {
"$ref": "#/components/schemas/MemoryResponse"
}
},
"required": [
"avatar",
"memories"
],
"type": "object"
},
"UserPreferencesUpdateDto": {
"properties": {
"avatar": {
"$ref": "#/components/schemas/AvatarUpdate"
},
"memories": {
"$ref": "#/components/schemas/MemoryUpdate"
}
},
"type": "object"
},
"UserResponseDto": {
"properties": {
"avatarColor": {
@ -11083,15 +11299,9 @@
},
"UserUpdateMeDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},

View file

@ -13,22 +13,13 @@ npm i --save @immich/sdk
For a more detailed example, check out the [`@immich/cli`](https://github.com/immich-app/immich/tree/main/cli).
```typescript
<<<<<<< HEAD
import { getAllAlbums, getAllAssets, getMyUser, init } from "@immich/sdk";
=======
import { getAllAlbums, getMyUserInfo, init } from "@immich/sdk";
>>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2
import { getAllAlbums, getMyUser, init } from "@immich/sdk";
const API_KEY = "<API_KEY>"; // process.env.IMMICH_API_KEY
init({ baseUrl: "https://demo.immich.app/api", apiKey: API_KEY });
<<<<<<< HEAD
const user = await getMyUser();
const assets = await getAllAssets({ take: 1000 });
=======
const user = await getMyUserInfo();
>>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2
const albums = await getAllAlbums({});
console.log({ user, albums });

View file

@ -45,7 +45,6 @@ export type UserAdminResponseDto = {
email: string;
id: string;
isAdmin: boolean;
memoriesEnabled?: boolean;
name: string;
oauthId: string;
profileImagePath: string;
@ -58,7 +57,6 @@ export type UserAdminResponseDto = {
};
export type UserAdminCreateDto = {
email: string;
memoriesEnabled?: boolean;
name: string;
notify?: boolean;
password: string;
@ -70,15 +68,33 @@ export type UserAdminDeleteDto = {
force?: boolean;
};
export type UserAdminUpdateDto = {
avatarColor?: UserAvatarColor;
email?: string;
memoriesEnabled?: boolean;
name?: string;
password?: string;
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string | null;
};
export type AvatarResponse = {
color: UserAvatarColor;
};
export type MemoryResponse = {
enabled: boolean;
};
export type UserPreferencesResponseDto = {
avatar: AvatarResponse;
memories: MemoryResponse;
};
export type AvatarUpdate = {
color?: UserAvatarColor;
};
export type MemoryUpdate = {
enabled?: boolean;
};
export type UserPreferencesUpdateDto = {
avatar?: AvatarUpdate;
memories?: MemoryUpdate;
};
export type AlbumUserResponseDto = {
role: AlbumUserRole;
user: UserResponseDto;
@ -1073,9 +1089,7 @@ export type TimeBucketResponseDto = {
timeBucket: string;
};
export type UserUpdateMeDto = {
avatarColor?: UserAvatarColor;
email?: string;
memoriesEnabled?: boolean;
name?: string;
password?: string;
};
@ -1200,6 +1214,29 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: {
body: userAdminUpdateDto
})));
}
export function getUserPreferencesAdmin({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserPreferencesResponseDto;
}>(`/admin/users/${encodeURIComponent(id)}/preferences`, {
...opts
}));
}
export function updateUserPreferencesAdmin({ id, userPreferencesUpdateDto }: {
id: string;
userPreferencesUpdateDto: UserPreferencesUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserPreferencesResponseDto;
}>(`/admin/users/${encodeURIComponent(id)}/preferences`, oazapfts.json({
...opts,
method: "PUT",
body: userPreferencesUpdateDto
})));
}
export function restoreUserAdmin({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
@ -2780,6 +2817,26 @@ export function updateMyUser({ userUpdateMeDto }: {
body: userUpdateMeDto
})));
}
export function getMyPreferences(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserPreferencesResponseDto;
}>("/users/me/preferences", {
...opts
}));
}
export function updateMyPreferences({ userPreferencesUpdateDto }: {
userPreferencesUpdateDto: UserPreferencesUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserPreferencesResponseDto;
}>("/users/me/preferences", oazapfts.json({
...opts,
method: "PUT",
body: userPreferencesUpdateDto
})));
}
export function deleteProfileImage(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/users/profile-image", {
...opts,

View file

@ -1,6 +1,7 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import {
UserAdminCreateDto,
UserAdminDeleteDto,
@ -55,6 +56,22 @@ export class UserAdminController {
return this.service.delete(auth, id, dto);
}
@Get(':id/preferences')
@Authenticated()
getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserPreferencesResponseDto> {
return this.service.getPreferences(auth, id);
}
@Put(':id/preferences')
@Authenticated()
updateUserPreferencesAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UserPreferencesUpdateDto,
): Promise<UserPreferencesResponseDto> {
return this.service.updatePreferences(auth, id, dto);
}
@Post(':id/restore')
@Authenticated({ admin: true })
restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {

View file

@ -17,6 +17,7 @@ import {
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AuthDto } from 'src/dtos/auth.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';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -52,6 +53,21 @@ export class UserController {
return this.service.updateMe(auth, dto);
}
@Get('me/preferences')
@Authenticated()
getMyPreferences(@Auth() auth: AuthDto): UserPreferencesResponseDto {
return this.service.getMyPreferences(auth);
}
@Put('me/preferences')
@Authenticated()
updateMyPreferences(
@Auth() auth: AuthDto,
@Body() dto: UserPreferencesUpdateDto,
): Promise<UserPreferencesResponseDto> {
return this.service.updateMyPreferences(auth, dto);
}
@Get(':id')
@Authenticated()
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {

View file

@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, ValidateNested } from 'class-validator';
import { UserAvatarColor, UserPreferences } from 'src/entities/user-metadata.entity';
import { Optional, ValidateBoolean } from 'src/validation';
class AvatarUpdate {
@Optional()
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
color?: UserAvatarColor;
}
class MemoryUpdate {
@ValidateBoolean({ optional: true })
enabled?: boolean;
}
export class UserPreferencesUpdateDto {
@Optional()
@ValidateNested()
@Type(() => AvatarUpdate)
avatar?: AvatarUpdate;
@Optional()
@ValidateNested()
@Type(() => MemoryUpdate)
memories?: MemoryUpdate;
}
class AvatarResponse {
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
color!: UserAvatarColor;
}
class MemoryResponse {
enabled!: boolean;
}
export class UserPreferencesResponseDto implements UserPreferences {
memories!: MemoryResponse;
avatar!: AvatarResponse;
}
export const mapPreferences = (preferences: UserPreferences): UserPreferencesResponseDto => {
return preferences;
};

View file

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator';
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator';
import { UserAvatarColor } from 'src/entities/user-metadata.entity';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { getPreferences } from 'src/utils/preferences';
@ -22,14 +22,6 @@ export class UserUpdateMeDto {
@IsString()
@IsNotEmpty()
name?: string;
@ValidateBoolean({ optional: true })
memoriesEnabled?: boolean;
@Optional()
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor;
}
export class UserResponseDto {
@ -37,7 +29,6 @@ export class UserResponseDto {
name!: string;
email!: string;
profileImagePath!: string;
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor!: UserAvatarColor;
}
@ -75,9 +66,6 @@ export class UserAdminCreateDto {
@Transform(toSanitized)
storageLabel?: string | null;
@ValidateBoolean({ optional: true })
memoriesEnabled?: boolean;
@Optional({ nullable: true })
@IsNumber()
@IsPositive()
@ -116,14 +104,6 @@ export class UserAdminUpdateDto {
@ValidateBoolean({ optional: true })
shouldChangePassword?: boolean;
@ValidateBoolean({ optional: true })
memoriesEnabled?: boolean;
@Optional()
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor;
@Optional({ nullable: true })
@IsNumber()
@IsPositive()
@ -144,7 +124,6 @@ export class UserAdminResponseDto extends UserResponseDto {
deletedAt!: Date | null;
updatedAt!: Date;
oauthId!: string;
memoriesEnabled?: boolean;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer', format: 'int64' })
@ -163,7 +142,6 @@ export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto {
deletedAt: entity.deletedAt,
updatedAt: entity.updatedAt,
oauthId: entity.oauthId,
memoriesEnabled: getPreferences(entity).memories.enabled,
quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes,
status: entity.status,

View file

@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@ne
import { SALT_ROUNDS } from 'src/constants';
import { UserCore } from 'src/cores/user.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import {
UserAdminCreateDto,
UserAdminDeleteDto,
@ -17,7 +18,7 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface';
import { getPreferences, getPreferencesPartial } from 'src/utils/preferences';
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
@Injectable()
export class UserAdminService {
@ -40,18 +41,8 @@ export class UserAdminService {
}
async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
const { memoriesEnabled, notify, ...rest } = dto;
let user = await this.userCore.createUser(rest);
// TODO remove and replace with entire dto.preferences config
if (memoriesEnabled === false) {
await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES,
value: { memories: { enabled: false } },
});
user = await this.findOrFail(user.id, {});
}
const { notify, ...rest } = dto;
const user = await this.userCore.createUser(rest);
const tempPassword = user.shouldChangePassword ? rest.password : undefined;
if (notify) {
@ -72,25 +63,6 @@ export class UserAdminService {
await this.userRepository.syncUsage(id);
}
// TODO replace with entire preferences object
if (dto.memoriesEnabled !== undefined || dto.avatarColor) {
const newPreferences = getPreferences(user);
if (dto.memoriesEnabled !== undefined) {
newPreferences.memories.enabled = dto.memoriesEnabled;
delete dto.memoriesEnabled;
}
if (dto.avatarColor) {
newPreferences.avatar.color = dto.avatarColor;
delete dto.avatarColor;
}
await this.userRepository.upsertMetadata(id, {
key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, newPreferences),
});
}
if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== id) {
@ -144,6 +116,24 @@ export class UserAdminService {
return mapUserAdmin(user);
}
async getPreferences(auth: AuthDto, id: string): Promise<UserPreferencesResponseDto> {
const user = await this.findOrFail(id, { withDeleted: false });
const preferences = getPreferences(user);
return mapPreferences(preferences);
}
async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) {
const user = await this.findOrFail(id, { withDeleted: false });
const preferences = mergePreferences(user, dto);
await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, preferences),
});
return mapPreferences(preferences);
}
private async findOrFail(id: string, options: UserFindOptions) {
const user = await this.userRepository.get(id, options);
if (!user) {

View file

@ -4,6 +4,7 @@ import { SALT_ROUNDS } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
@ -16,7 +17,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { getPreferences, getPreferencesPartial } from 'src/utils/preferences';
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
@Injectable()
export class UserService {
@ -45,25 +46,6 @@ export class UserService {
}
async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
// TODO replace with entire preferences object
if (dto.memoriesEnabled !== undefined || dto.avatarColor) {
const newPreferences = getPreferences(user);
if (dto.memoriesEnabled !== undefined) {
newPreferences.memories.enabled = dto.memoriesEnabled;
delete dto.memoriesEnabled;
}
if (dto.avatarColor) {
newPreferences.avatar.color = dto.avatarColor;
delete dto.avatarColor;
}
await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, newPreferences),
});
}
if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== user.id) {
@ -87,6 +69,22 @@ export class UserService {
return mapUserAdmin(updatedUser);
}
getMyPreferences({ user }: AuthDto): UserPreferencesResponseDto {
const preferences = getPreferences(user);
return mapPreferences(preferences);
}
async updateMyPreferences({ user }: AuthDto, dto: UserPreferencesUpdateDto) {
const preferences = mergePreferences(user, dto);
await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, preferences),
});
return mapPreferences(preferences);
}
async get(id: string): Promise<UserResponseDto> {
const user = await this.findOrFail(id, { withDeleted: false });
return mapUser(user);

View file

@ -1,4 +1,5 @@
import _ from 'lodash';
import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { UserMetadataKey, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { getKeysDeep } from 'src/utils/misc';
@ -37,3 +38,12 @@ export const getPreferencesPartial = (user: { email: string }, newPreferences: U
return partial;
};
export const mergePreferences = (user: UserEntity, dto: UserPreferencesUpdateDto) => {
const preferences = getPreferences(user);
for (const key of getKeysDeep(dto)) {
_.set(preferences, key, _.get(dto, key));
}
return preferences;
};

View file

@ -1,18 +1,18 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
import { AppRoute } from '$lib/constants';
import { user } from '$lib/stores/user.store';
import { preferences, user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { deleteProfileImage, updateMyUser, type UserAvatarColor } from '@immich/sdk';
import { deleteProfileImage, updateMyPreferences, type UserAvatarColor } from '@immich/sdk';
import { mdiCog, mdiLogout, mdiPencil } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import { notificationController, NotificationType } from '../notification/notification';
import { NotificationType, notificationController } from '../notification/notification';
import UserAvatar from '../user-avatar.svelte';
import AvatarSelector from './avatar-selector.svelte';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
let isShowSelectAvatar = false;
@ -27,14 +27,7 @@
await deleteProfileImage();
}
$user = await updateMyUser({
userUpdateMeDto: {
email: $user.email,
name: $user.name,
avatarColor: color,
},
});
$preferences = await updateMyPreferences({ userPreferencesUpdateDto: { avatar: { color } } });
isShowSelectAvatar = false;
notificationController.show({

View file

@ -3,20 +3,20 @@
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { updateMyUser, type UserAdminResponseDto } from '@immich/sdk';
import { updateMyPreferences } from '@immich/sdk';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { preferences } from '$lib/stores/user.store';
import Button from '../elements/buttons/button.svelte';
export let user: UserAdminResponseDto;
let memoriesEnabled = $preferences?.memories?.enabled ?? false;
const handleSave = async () => {
try {
const data = await updateMyUser({ userUpdateMeDto: { memoriesEnabled: user.memoriesEnabled } });
Object.assign(user, data);
const data = await updateMyPreferences({ userPreferencesUpdateDto: { memories: { enabled: memoriesEnabled } } });
$preferences.memories.enabled = data.memories.enabled;
notificationController.show({ message: 'Saved settings', type: NotificationType.Info });
} catch (error) {
@ -34,7 +34,7 @@
id="time-based-memories"
title="Time-based memories"
subtitle="Photos from previous years"
bind:checked={user.memoriesEnabled}
bind:checked={memoriesEnabled}
/>
</div>
<div class="flex justify-end">

View file

@ -42,7 +42,7 @@
</SettingAccordion>
<SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories">
<MemoriesSettings user={$user} />
<MemoriesSettings />
</SettingAccordion>
{#if $featureFlags.loaded && $featureFlags.oauth}

View file

@ -1,7 +1,8 @@
import type { UserAdminResponseDto } from '@immich/sdk';
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
import { writable } from 'svelte/store';
export const user = writable<UserAdminResponseDto>();
export const preferences = writable<UserPreferencesResponseDto>();
/**
* Reset the store to its initial undefined value. Make sure to
@ -9,4 +10,5 @@ export const user = writable<UserAdminResponseDto>();
*/
export const resetSavedUser = () => {
user.set(undefined as unknown as UserAdminResponseDto);
preferences.set(undefined as unknown as UserPreferencesResponseDto);
};

View file

@ -1,7 +1,7 @@
import { browser } from '$app/environment';
import { serverInfo } from '$lib/stores/server-info.store';
import { user } from '$lib/stores/user.store';
import { getMyUser, getStorage } from '@immich/sdk';
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
import { getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store';
import { AppRoute } from '../constants';
@ -13,12 +13,14 @@ export interface AuthOptions {
export const loadUser = async () => {
try {
let loaded = get(user);
if (!loaded && hasAuthCookie()) {
loaded = await getMyUser();
user.set(loaded);
let user = get(user$);
let preferences = get(preferences$);
if ((!user || !preferences) && hasAuthCookie()) {
[user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]);
user$.set(user);
preferences$.set(preferences);
}
return loaded;
return user;
} catch {
return null;
}
@ -57,7 +59,7 @@ export const authenticate = async (options?: AuthOptions) => {
};
export const requestServerInfo = async () => {
if (get(user)) {
if (get(user$)) {
const data = await getStorage();
serverInfo.set(data);
}

View file

@ -22,7 +22,7 @@
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { user } from '$lib/stores/user.store';
import { preferences, user } from '$lib/stores/user.store';
let { isViewing: showAssetViewer } = assetViewingStore;
let handleEscapeKey = false;
@ -98,7 +98,7 @@
on:escape={handleEscape}
withStacked
>
{#if $user.memoriesEnabled}
{#if $preferences.memories.enabled}
<MemoryLane />
{/if}
<EmptyPlaceholder text="CLICK TO UPLOAD YOUR FIRST PHOTO" onClick={() => openFileUploadDialog()} slot="empty" />