1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-27 22:22:45 +01:00

feat(server): user preferences ()

* 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, deletedAt: null,
updatedAt: 2023-09-21T15:42:28.129Z, updatedAt: 2023-09-21T15:42:28.129Z,
oauthId: '', 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 { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures'; import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
@ -103,15 +110,7 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden); expect(body).toEqual(errorDto.forbidden);
}); });
for (const key of [ for (const key of ['password', 'email', 'name', 'quotaSizeInBytes', 'shouldChangePassword', 'notify']) {
'password',
'email',
'name',
'quotaSizeInBytes',
'shouldChangePassword',
'memoriesEnabled',
'notify',
]) {
it(`should not allow null ${key}`, async () => { it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.post(`/admin/users`) .post(`/admin/users`)
@ -139,23 +138,6 @@ describe('/admin/users', () => {
}); });
expect(status).toBe(201); 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', () => { describe('PUT /admin/users/:id', () => {
@ -173,7 +155,7 @@ describe('/admin/users', () => {
expect(body).toEqual(errorDto.forbidden); 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 () => { it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/admin/users/${uuidDto.notFound}`) .put(`/admin/users/${uuidDto.notFound}`)
@ -221,22 +203,6 @@ describe('/admin/users', () => {
expect(before.updatedAt).not.toEqual(body.updatedAt); 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 () => { it('should update password', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/admin/users/${nonAdmin.userId}`) .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', () => { describe('DELETE /admin/users/:id', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`); 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 { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils'; import { app, asBearerAuth, utils } from 'src/utils';
@ -69,7 +69,6 @@ describe('/users', () => {
expect(body).toMatchObject({ expect(body).toMatchObject({
id: admin.userId, id: admin.userId,
email: 'admin@immich.cloud', email: 'admin@immich.cloud',
memoriesEnabled: true,
quotaUsageInBytes: 0, quotaUsageInBytes: 0,
}); });
}); });
@ -82,7 +81,7 @@ describe('/users', () => {
expect(body).toEqual(errorDto.unauthorized); 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 () => { it(`should not allow null ${key}`, async () => {
const dto = { [key]: null }; const dto = { [key]: null };
const { status, body } = await request(app) 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 */ /** @deprecated */
it('should allow a user to change their password (deprecated)', async () => { it('should allow a user to change their password (deprecated)', async () => {
const user = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) }); 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', () => { describe('GET /users/:id', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status } = await request(app).get(`/users/${admin.userId}`); const { status } = await request(app).get(`/users/${admin.userId}`);
@ -194,7 +193,6 @@ describe('/users', () => {
expect(body).not.toMatchObject({ expect(body).not.toMatchObject({
shouldChangePassword: expect.anything(), shouldChangePassword: expect.anything(),
memoriesEnabled: expect.anything(),
storageLabel: expect.anything(), storageLabel: expect.anything(),
}); });
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -58,6 +58,8 @@ class TabNavigationObserver extends AutoRouterObserver {
try { try {
final userResponseDto = final userResponseDto =
await ref.read(apiServiceProvider).userApi.getMyUser(); await ref.read(apiServiceProvider).userApi.getMyUser();
final userPreferences =
await ref.read(apiServiceProvider).userApi.getMyPreferences();
if (userResponseDto == null) { if (userResponseDto == null) {
return; return;
@ -65,7 +67,7 @@ class TabNavigationObserver extends AutoRouterObserver {
Store.put( Store.put(
StoreKey.currentUser, StoreKey.currentUser,
User.fromUserDto(userResponseDto), User.fromUserDto(userResponseDto, userPreferences),
); );
ref.read(serverInfoProvider.notifier).getServerVersion(); ref.read(serverInfoProvider.notifier).getServerVersion();
} catch (e) { } 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": { "/admin/users/{id}/restore": {
"post": { "post": {
"operationId": "restoreUserAdmin", "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": { "/users/profile-image": {
"delete": { "delete": {
"operationId": "deleteProfileImage", "operationId": "deleteProfileImage",
@ -7621,6 +7785,25 @@
], ],
"type": "object" "type": "object"
}, },
"AvatarResponse": {
"properties": {
"color": {
"$ref": "#/components/schemas/UserAvatarColor"
}
},
"required": [
"color"
],
"type": "object"
},
"AvatarUpdate": {
"properties": {
"color": {
"$ref": "#/components/schemas/UserAvatarColor"
}
},
"type": "object"
},
"BulkIdResponseDto": { "BulkIdResponseDto": {
"properties": { "properties": {
"error": { "error": {
@ -8584,6 +8767,17 @@
], ],
"type": "object" "type": "object"
}, },
"MemoryResponse": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
],
"type": "object"
},
"MemoryResponseDto": { "MemoryResponseDto": {
"properties": { "properties": {
"assets": { "assets": {
@ -8650,6 +8844,14 @@
], ],
"type": "string" "type": "string"
}, },
"MemoryUpdate": {
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"MemoryUpdateDto": { "MemoryUpdateDto": {
"properties": { "properties": {
"isSaved": { "isSaved": {
@ -10878,9 +11080,6 @@
"email": { "email": {
"type": "string" "type": "string"
}, },
"memoriesEnabled": {
"type": "boolean"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@ -10942,9 +11141,6 @@
"isAdmin": { "isAdmin": {
"type": "boolean" "type": "boolean"
}, },
"memoriesEnabled": {
"type": "boolean"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@ -11000,15 +11196,9 @@
}, },
"UserAdminUpdateDto": { "UserAdminUpdateDto": {
"properties": { "properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": { "email": {
"type": "string" "type": "string"
}, },
"memoriesEnabled": {
"type": "boolean"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
@ -11046,6 +11236,32 @@
], ],
"type": "string" "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": { "UserResponseDto": {
"properties": { "properties": {
"avatarColor": { "avatarColor": {
@ -11083,15 +11299,9 @@
}, },
"UserUpdateMeDto": { "UserUpdateMeDto": {
"properties": { "properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": { "email": {
"type": "string" "type": "string"
}, },
"memoriesEnabled": {
"type": "boolean"
},
"name": { "name": {
"type": "string" "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). For a more detailed example, check out the [`@immich/cli`](https://github.com/immich-app/immich/tree/main/cli).
```typescript ```typescript
<<<<<<< HEAD import { getAllAlbums, getMyUser, init } from "@immich/sdk";
import { getAllAlbums, getAllAssets, getMyUser, init } from "@immich/sdk";
=======
import { getAllAlbums, getMyUserInfo, init } from "@immich/sdk";
>>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2
const API_KEY = "<API_KEY>"; // process.env.IMMICH_API_KEY const API_KEY = "<API_KEY>"; // process.env.IMMICH_API_KEY
init({ baseUrl: "https://demo.immich.app/api", apiKey: API_KEY }); init({ baseUrl: "https://demo.immich.app/api", apiKey: API_KEY });
<<<<<<< HEAD
const user = await getMyUser(); const user = await getMyUser();
const assets = await getAllAssets({ take: 1000 });
=======
const user = await getMyUserInfo();
>>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2
const albums = await getAllAlbums({}); const albums = await getAllAlbums({});
console.log({ user, albums }); console.log({ user, albums });

View file

@ -45,7 +45,6 @@ export type UserAdminResponseDto = {
email: string; email: string;
id: string; id: string;
isAdmin: boolean; isAdmin: boolean;
memoriesEnabled?: boolean;
name: string; name: string;
oauthId: string; oauthId: string;
profileImagePath: string; profileImagePath: string;
@ -58,7 +57,6 @@ export type UserAdminResponseDto = {
}; };
export type UserAdminCreateDto = { export type UserAdminCreateDto = {
email: string; email: string;
memoriesEnabled?: boolean;
name: string; name: string;
notify?: boolean; notify?: boolean;
password: string; password: string;
@ -70,15 +68,33 @@ export type UserAdminDeleteDto = {
force?: boolean; force?: boolean;
}; };
export type UserAdminUpdateDto = { export type UserAdminUpdateDto = {
avatarColor?: UserAvatarColor;
email?: string; email?: string;
memoriesEnabled?: boolean;
name?: string; name?: string;
password?: string; password?: string;
quotaSizeInBytes?: number | null; quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean; shouldChangePassword?: boolean;
storageLabel?: string | null; 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 = { export type AlbumUserResponseDto = {
role: AlbumUserRole; role: AlbumUserRole;
user: UserResponseDto; user: UserResponseDto;
@ -1073,9 +1089,7 @@ export type TimeBucketResponseDto = {
timeBucket: string; timeBucket: string;
}; };
export type UserUpdateMeDto = { export type UserUpdateMeDto = {
avatarColor?: UserAvatarColor;
email?: string; email?: string;
memoriesEnabled?: boolean;
name?: string; name?: string;
password?: string; password?: string;
}; };
@ -1200,6 +1214,29 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: {
body: 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 }: { export function restoreUserAdmin({ id }: {
id: string; id: string;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
@ -2780,6 +2817,26 @@ export function updateMyUser({ userUpdateMeDto }: {
body: 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) { export function deleteProfileImage(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/users/profile-image", { return oazapfts.ok(oazapfts.fetchText("/users/profile-image", {
...opts, ...opts,

View file

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

View file

@ -17,6 +17,7 @@ import {
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from 'express';
import { AuthDto } from 'src/dtos/auth.dto'; 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 { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -52,6 +53,21 @@ export class UserController {
return this.service.updateMe(auth, dto); 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') @Get(':id')
@Authenticated() @Authenticated()
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> { 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 { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; 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 { UserAvatarColor } from 'src/entities/user-metadata.entity';
import { UserEntity, UserStatus } from 'src/entities/user.entity'; import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { getPreferences } from 'src/utils/preferences'; import { getPreferences } from 'src/utils/preferences';
@ -22,14 +22,6 @@ export class UserUpdateMeDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name?: string; name?: string;
@ValidateBoolean({ optional: true })
memoriesEnabled?: boolean;
@Optional()
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor;
} }
export class UserResponseDto { export class UserResponseDto {
@ -37,7 +29,6 @@ export class UserResponseDto {
name!: string; name!: string;
email!: string; email!: string;
profileImagePath!: string; profileImagePath!: string;
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor }) @ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor!: UserAvatarColor; avatarColor!: UserAvatarColor;
} }
@ -75,9 +66,6 @@ export class UserAdminCreateDto {
@Transform(toSanitized) @Transform(toSanitized)
storageLabel?: string | null; storageLabel?: string | null;
@ValidateBoolean({ optional: true })
memoriesEnabled?: boolean;
@Optional({ nullable: true }) @Optional({ nullable: true })
@IsNumber() @IsNumber()
@IsPositive() @IsPositive()
@ -116,14 +104,6 @@ export class UserAdminUpdateDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
shouldChangePassword?: boolean; shouldChangePassword?: boolean;
@ValidateBoolean({ optional: true })
memoriesEnabled?: boolean;
@Optional()
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor;
@Optional({ nullable: true }) @Optional({ nullable: true })
@IsNumber() @IsNumber()
@IsPositive() @IsPositive()
@ -144,7 +124,6 @@ export class UserAdminResponseDto extends UserResponseDto {
deletedAt!: Date | null; deletedAt!: Date | null;
updatedAt!: Date; updatedAt!: Date;
oauthId!: string; oauthId!: string;
memoriesEnabled?: boolean;
@ApiProperty({ type: 'integer', format: 'int64' }) @ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes!: number | null; quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer', format: 'int64' }) @ApiProperty({ type: 'integer', format: 'int64' })
@ -163,7 +142,6 @@ export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto {
deletedAt: entity.deletedAt, deletedAt: entity.deletedAt,
updatedAt: entity.updatedAt, updatedAt: entity.updatedAt,
oauthId: entity.oauthId, oauthId: entity.oauthId,
memoriesEnabled: getPreferences(entity).memories.enabled,
quotaSizeInBytes: entity.quotaSizeInBytes, quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes, quotaUsageInBytes: entity.quotaUsageInBytes,
status: entity.status, status: entity.status,

View file

@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@ne
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { UserCore } from 'src/cores/user.core'; import { UserCore } from 'src/cores/user.core';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { import {
UserAdminCreateDto, UserAdminCreateDto,
UserAdminDeleteDto, UserAdminDeleteDto,
@ -17,7 +18,7 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IUserRepository, UserFindOptions } from 'src/interfaces/user.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() @Injectable()
export class UserAdminService { export class UserAdminService {
@ -40,18 +41,8 @@ export class UserAdminService {
} }
async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> { async create(dto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
const { memoriesEnabled, notify, ...rest } = dto; const { notify, ...rest } = dto;
let user = await this.userCore.createUser(rest); const 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 tempPassword = user.shouldChangePassword ? rest.password : undefined; const tempPassword = user.shouldChangePassword ? rest.password : undefined;
if (notify) { if (notify) {
@ -72,25 +63,6 @@ export class UserAdminService {
await this.userRepository.syncUsage(id); 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) { if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email); const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== id) { if (duplicate && duplicate.id !== id) {
@ -144,6 +116,24 @@ export class UserAdminService {
return mapUserAdmin(user); 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) { private async findOrFail(id: string, options: UserFindOptions) {
const user = await this.userRepository.get(id, options); const user = await this.userRepository.get(id, options);
if (!user) { if (!user) {

View file

@ -4,6 +4,7 @@ import { SALT_ROUNDS } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { AuthDto } from 'src/dtos/auth.dto'; 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 { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { UserMetadataKey } from 'src/entities/user-metadata.entity'; 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 { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface';
import { CacheControl, ImmichFileResponse } from 'src/utils/file'; import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { getPreferences, getPreferencesPartial } from 'src/utils/preferences'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
@Injectable() @Injectable()
export class UserService { export class UserService {
@ -45,25 +46,6 @@ export class UserService {
} }
async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> { 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) { if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email); const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== user.id) { if (duplicate && duplicate.id !== user.id) {
@ -87,6 +69,22 @@ export class UserService {
return mapUserAdmin(updatedUser); 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> { async get(id: string): Promise<UserResponseDto> {
const user = await this.findOrFail(id, { withDeleted: false }); const user = await this.findOrFail(id, { withDeleted: false });
return mapUser(user); return mapUser(user);

View file

@ -1,4 +1,5 @@
import _ from 'lodash'; import _ from 'lodash';
import { UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { UserMetadataKey, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity'; import { UserMetadataKey, UserPreferences, getDefaultPreferences } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { getKeysDeep } from 'src/utils/misc'; import { getKeysDeep } from 'src/utils/misc';
@ -37,3 +38,12 @@ export const getPreferencesPartial = (user: { email: string }, newPreferences: U
return partial; 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"> <script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte'; 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 Icon from '$lib/components/elements/icon.svelte';
import FocusTrap from '$lib/components/shared-components/focus-trap.svelte';
import { AppRoute } from '$lib/constants'; 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 { 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 { mdiCog, mdiLogout, mdiPencil } from '@mdi/js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { notificationController, NotificationType } from '../notification/notification'; import { NotificationType, notificationController } from '../notification/notification';
import UserAvatar from '../user-avatar.svelte'; import UserAvatar from '../user-avatar.svelte';
import AvatarSelector from './avatar-selector.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; let isShowSelectAvatar = false;
@ -27,14 +27,7 @@
await deleteProfileImage(); await deleteProfileImage();
} }
$user = await updateMyUser({ $preferences = await updateMyPreferences({ userPreferencesUpdateDto: { avatar: { color } } });
userUpdateMeDto: {
email: $user.email,
name: $user.name,
avatarColor: color,
},
});
isShowSelectAvatar = false; isShowSelectAvatar = false;
notificationController.show({ notificationController.show({

View file

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

View file

@ -42,7 +42,7 @@
</SettingAccordion> </SettingAccordion>
<SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories"> <SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories">
<MemoriesSettings user={$user} /> <MemoriesSettings />
</SettingAccordion> </SettingAccordion>
{#if $featureFlags.loaded && $featureFlags.oauth} {#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'; import { writable } from 'svelte/store';
export const user = writable<UserAdminResponseDto>(); export const user = writable<UserAdminResponseDto>();
export const preferences = writable<UserPreferencesResponseDto>();
/** /**
* Reset the store to its initial undefined value. Make sure to * Reset the store to its initial undefined value. Make sure to
@ -9,4 +10,5 @@ export const user = writable<UserAdminResponseDto>();
*/ */
export const resetSavedUser = () => { export const resetSavedUser = () => {
user.set(undefined as unknown as UserAdminResponseDto); 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 { browser } from '$app/environment';
import { serverInfo } from '$lib/stores/server-info.store'; import { serverInfo } from '$lib/stores/server-info.store';
import { user } from '$lib/stores/user.store'; import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
import { getMyUser, getStorage } from '@immich/sdk'; import { getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { AppRoute } from '../constants'; import { AppRoute } from '../constants';
@ -13,12 +13,14 @@ export interface AuthOptions {
export const loadUser = async () => { export const loadUser = async () => {
try { try {
let loaded = get(user); let user = get(user$);
if (!loaded && hasAuthCookie()) { let preferences = get(preferences$);
loaded = await getMyUser(); if ((!user || !preferences) && hasAuthCookie()) {
user.set(loaded); [user, preferences] = await Promise.all([getMyUser(), getMyPreferences()]);
user$.set(user);
preferences$.set(preferences);
} }
return loaded; return user;
} catch { } catch {
return null; return null;
} }
@ -57,7 +59,7 @@ export const authenticate = async (options?: AuthOptions) => {
}; };
export const requestServerInfo = async () => { export const requestServerInfo = async () => {
if (get(user)) { if (get(user$)) {
const data = await getStorage(); const data = await getStorage();
serverInfo.set(data); serverInfo.set(data);
} }

View file

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