1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

refactor(server): user endpoints (#9730)

* refactor(server): user endpoints

* fix repos

* fix unit tests

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2024-05-26 18:15:52 -04:00 committed by GitHub
parent e7c8501930
commit 75830a4878
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 1696 additions and 1341 deletions

View file

@ -1,4 +1,4 @@
import { getMyUserInfo } from '@immich/sdk'; import { getMyUser } from '@immich/sdk';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { mkdir, unlink } from 'node:fs/promises'; import { mkdir, unlink } from 'node:fs/promises';
import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils'; import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils';
@ -10,13 +10,13 @@ export const login = async (url: string, key: string, options: BaseOptions) => {
await connect(url, key); await connect(url, key);
const [error, userInfo] = await withError(getMyUserInfo()); const [error, user] = await withError(getMyUser());
if (error) { if (error) {
logError(error, 'Failed to load user info'); logError(error, 'Failed to load user info');
process.exit(1); process.exit(1);
} }
console.log(`Logged in as ${userInfo.email}`); console.log(`Logged in as ${user.email}`);
if (!existsSync(configDir)) { if (!existsSync(configDir)) {
// Create config folder if it doesn't exist // Create config folder if it doesn't exist

View file

@ -1,4 +1,4 @@
import { getAssetStatistics, getMyUserInfo, getServerVersion, getSupportedMediaTypes } from '@immich/sdk'; import { getAssetStatistics, getMyUser, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
import { BaseOptions, authenticate } from 'src/utils'; import { BaseOptions, authenticate } from 'src/utils';
export const serverInfo = async (options: BaseOptions) => { export const serverInfo = async (options: BaseOptions) => {
@ -8,7 +8,7 @@ export const serverInfo = async (options: BaseOptions) => {
getServerVersion(), getServerVersion(),
getSupportedMediaTypes(), getSupportedMediaTypes(),
getAssetStatistics({}), getAssetStatistics({}),
getMyUserInfo(), getMyUser(),
]); ]);
console.log(`Server Info (via ${userInfo.email})`); console.log(`Server Info (via ${userInfo.email})`);

View file

@ -1,4 +1,4 @@
import { getMyUserInfo, init, isHttpError } from '@immich/sdk'; import { getMyUser, init, isHttpError } from '@immich/sdk';
import { glob } from 'fast-glob'; import { glob } from 'fast-glob';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
@ -48,7 +48,7 @@ export const connect = async (url: string, key: string) => {
init({ baseUrl: url, apiKey: key }); init({ baseUrl: url, apiKey: key });
const [error] = await withError(getMyUserInfo()); const [error] = await withError(getMyUser());
if (isHttpError(error)) { if (isHttpError(error)) {
logError(error, 'Failed to connect to server'); logError(error, 'Failed to connect to server');
process.exit(1); process.exit(1);

View file

@ -4,7 +4,7 @@ import {
AlbumUserRole, AlbumUserRole,
AssetFileUploadResponseDto, AssetFileUploadResponseDto,
AssetOrder, AssetOrder,
deleteUser, deleteUserAdmin,
getAlbumInfo, getAlbumInfo,
LoginResponseDto, LoginResponseDto,
SharedLinkType, SharedLinkType,
@ -107,7 +107,7 @@ describe('/albums', () => {
}), }),
]); ]);
await deleteUser({ id: user3.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
}); });
describe('GET /albums', () => { describe('GET /albums', () => {

View file

@ -5,7 +5,7 @@ import {
LoginResponseDto, LoginResponseDto,
SharedLinkType, SharedLinkType,
getAssetInfo, getAssetInfo,
getMyUserInfo, getMyUser,
updateAssets, updateAssets,
} from '@immich/sdk'; } from '@immich/sdk';
import { exiftool } from 'exiftool-vendored'; import { exiftool } from 'exiftool-vendored';
@ -1162,7 +1162,7 @@ describe('/asset', () => {
expect(body).toEqual({ id: expect.any(String), duplicate: false }); expect(body).toEqual({ id: expect.any(String), duplicate: false });
expect(status).toBe(201); expect(status).toBe(201);
const user = await getMyUserInfo({ headers: asBearerAuth(quotaUser.accessToken) }); const user = await getMyUser({ headers: asBearerAuth(quotaUser.accessToken) });
expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 })); expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 }));
}); });

View file

@ -5,7 +5,7 @@ import {
SharedLinkResponseDto, SharedLinkResponseDto,
SharedLinkType, SharedLinkType,
createAlbum, createAlbum,
deleteUser, deleteUserAdmin,
} from '@immich/sdk'; } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures'; import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
@ -86,7 +86,7 @@ describe('/shared-links', () => {
}), }),
]); ]);
await deleteUser({ id: user2.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); await deleteUserAdmin({ id: user2.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
}); });
describe('GET /share/${key}', () => { describe('GET /share/${key}', () => {

View file

@ -0,0 +1,317 @@
import { LoginResponseDto, deleteUserAdmin, getMyUser, getUserAdmin, login } from '@immich/sdk';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/admin/users', () => {
let websocket: Socket;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
let deletedUser: LoginResponseDto;
let userToDelete: LoginResponseDto;
let userToHardDelete: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
[websocket, nonAdmin, deletedUser, userToDelete, userToHardDelete] = await Promise.all([
utils.connectWebsocket(admin.accessToken),
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3),
utils.userSetup(admin.accessToken, createUserDto.user4),
]);
await deleteUserAdmin(
{ id: deletedUser.userId, userAdminDeleteDto: {} },
{ headers: asBearerAuth(admin.accessToken) },
);
});
afterAll(() => {
utils.disconnectWebsocket(websocket);
});
describe('GET /admin/users', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/admin/users`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const { status, body } = await request(app)
.get(`/admin/users`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should hide deleted users by default', async () => {
const { status, body } = await request(app)
.get(`/admin/users`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: admin.userEmail }),
expect.objectContaining({ email: nonAdmin.userEmail }),
expect.objectContaining({ email: userToDelete.userEmail }),
expect.objectContaining({ email: userToHardDelete.userEmail }),
]),
);
});
it('should include deleted users', async () => {
const { status, body } = await request(app)
.get(`/admin/users?withDeleted=true`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(5);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: admin.userEmail }),
expect.objectContaining({ email: nonAdmin.userEmail }),
expect.objectContaining({ email: userToDelete.userEmail }),
expect.objectContaining({ email: userToHardDelete.userEmail }),
expect.objectContaining({ email: deletedUser.userEmail }),
]),
);
});
});
describe('POST /admin/users', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/admin/users`).send(createUserDto.user1);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const { status, body } = await request(app)
.post(`/admin/users`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send(createUserDto.user1);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
for (const key of [
'password',
'email',
'name',
'quotaSizeInBytes',
'shouldChangePassword',
'memoriesEnabled',
'notify',
]) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.post(`/admin/users`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...createUserDto.user1, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
}
it('should ignore `isAdmin`', async () => {
const { status, body } = await request(app)
.post(`/admin/users`)
.send({
isAdmin: true,
email: 'user5@immich.cloud',
password: 'password123',
name: 'Immich',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({
email: 'user5@immich.cloud',
isAdmin: false,
shouldChangePassword: true,
});
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', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/admin/users/${uuidDto.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
for (const key of ['password', 'email', 'name', 'shouldChangePassword', 'memoriesEnabled']) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.put(`/admin/users/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
}
it('should not allow a non-admin to become an admin', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${nonAdmin.userId}`)
.send({ isAdmin: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ isAdmin: false });
});
it('ignores updates to profileImagePath', async () => {
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}`)
.send({ profileImagePath: 'invalid.jpg' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' });
});
it('should update first and last name', async () => {
const before = await getUserAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app)
.put(`/admin/users/${admin.userId}`)
.send({ name: 'Name' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...before,
updatedAt: expect.any(String),
name: 'Name',
});
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}`)
.send({ password: 'super-secret' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ email: nonAdmin.userEmail });
const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } });
expect(token.accessToken).toBeDefined();
const user = await getMyUser({ headers: asBearerAuth(token.accessToken) });
expect(user).toMatchObject({ email: nonAdmin.userEmail });
});
});
describe('DELETE /admin/users/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/admin/users/${userToDelete.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const { status, body } = await request(app)
.delete(`/admin/users/${userToDelete.userId}`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should delete user', async () => {
const { status, body } = await request(app)
.delete(`/admin/users/${userToDelete.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: userToDelete.userId,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
});
it('should hard delete a user', async () => {
const { status, body } = await request(app)
.delete(`/admin/users/${userToHardDelete.userId}`)
.send({ force: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: userToHardDelete.userId,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
await utils.waitForWebsocketEvent({ event: 'userDelete', id: userToHardDelete.userId, timeout: 5000 });
});
});
describe('POST /admin/users/:id/restore', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/admin/users/${userToDelete.userId}/restore`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require authorization', async () => {
const { status, body } = await request(app)
.post(`/admin/users/${userToDelete.userId}/restore`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
});
});

View file

@ -1,37 +1,28 @@
import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk'; import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyUser, login } from '@immich/sdk';
import { Socket } from 'socket.io-client'; import { createUserDto } from 'src/fixtures';
import { createUserDto, userDto } 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';
import request from 'supertest'; import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
describe('/users', () => { describe('/users', () => {
let websocket: Socket;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let deletedUser: LoginResponseDto; let deletedUser: LoginResponseDto;
let userToDelete: LoginResponseDto;
let userToHardDelete: LoginResponseDto;
let nonAdmin: LoginResponseDto; let nonAdmin: LoginResponseDto;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false }); admin = await utils.adminSetup({ onboarding: false });
[websocket, deletedUser, nonAdmin, userToDelete, userToHardDelete] = await Promise.all([ [deletedUser, nonAdmin] = await Promise.all([
utils.connectWebsocket(admin.accessToken),
utils.userSetup(admin.accessToken, createUserDto.user1), utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2), utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3),
utils.userSetup(admin.accessToken, createUserDto.user4),
]); ]);
await deleteUser({ id: deletedUser.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); await deleteUserAdmin(
}); { id: deletedUser.userId, userAdminDeleteDto: {} },
{ headers: asBearerAuth(admin.accessToken) },
afterAll(() => { );
utils.disconnectWebsocket(websocket);
}); });
describe('GET /users', () => { describe('GET /users', () => {
@ -44,71 +35,14 @@ describe('/users', () => {
it('should get users', async () => { it('should get users', async () => {
const { status, body } = await request(app).get('/users').set('Authorization', `Bearer ${admin.accessToken}`); const { status, body } = await request(app).get('/users').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200); expect(status).toEqual(200);
expect(body).toHaveLength(5); expect(body).toHaveLength(2);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
expect.objectContaining({ email: 'user4@immich.cloud' }),
]),
);
});
it('should hide deleted users', async () => {
const { status, body } = await request(app)
.get(`/users`)
.query({ isAll: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
expect(body).toEqual( expect(body).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }), expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }), expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
expect.objectContaining({ email: 'user4@immich.cloud' }),
]), ]),
); );
}); });
it('should include deleted users', async () => {
const { status, body } = await request(app)
.get(`/users`)
.query({ isAll: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(5);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
expect.objectContaining({ email: 'user4@immich.cloud' }),
]),
);
});
});
describe('GET /users/:id', () => {
it('should require authentication', async () => {
const { status } = await request(app).get(`/users/${admin.userId}`);
expect(status).toEqual(401);
});
it('should get the user info', async () => {
const { status, body } = await request(app)
.get(`/users/${admin.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
});
});
}); });
describe('GET /users/me', () => { describe('GET /users/me', () => {
@ -118,154 +52,54 @@ describe('/users', () => {
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should get my info', async () => { it('should not work for shared links', async () => {
const album = await utils.createAlbum(admin.accessToken, { albumName: 'Album' });
const sharedLink = await utils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
});
const { status, body } = await request(app).get(`/users/me?key=${sharedLink.key}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should get my user', async () => {
const { status, body } = await request(app).get(`/users/me`).set('Authorization', `Bearer ${admin.accessToken}`); const { status, body } = await request(app).get(`/users/me`).set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toMatchObject({ expect(body).toMatchObject({
id: admin.userId, id: admin.userId,
email: 'admin@immich.cloud', email: 'admin@immich.cloud',
memoriesEnabled: true,
quotaUsageInBytes: 0,
}); });
}); });
}); });
describe('POST /users', () => { describe('PUT /users/me', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).post(`/users`).send(createUserDto.user1); const { status, body } = await request(app).put(`/users/me`);
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
for (const key of Object.keys(createUserDto.user1)) { for (const key of ['email', 'name', 'memoriesEnabled', 'avatarColor']) {
it(`should not allow null ${key}`, async () => { it(`should not allow null ${key}`, async () => {
const dto = { [key]: null };
const { status, body } = await request(app) const { status, body } = await request(app)
.post(`/users`) .put(`/users/me`)
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...createUserDto.user1, [key]: null }); .send(dto);
expect(status).toBe(400); expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest()); expect(body).toEqual(errorDto.badRequest());
}); });
} }
it('should ignore `isAdmin`', async () => {
const { status, body } = await request(app)
.post(`/users`)
.send({
isAdmin: true,
email: 'user5@immich.cloud',
password: 'password123',
name: 'Immich',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({
email: 'user5@immich.cloud',
isAdmin: false,
shouldChangePassword: true,
});
expect(status).toBe(201);
});
it('should create a user without memories enabled', async () => {
const { status, body } = await request(app)
.post(`/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('DELETE /users/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/users/${userToDelete.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should delete user', async () => {
const { status, body } = await request(app)
.delete(`/users/${userToDelete.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: userToDelete.userId,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
});
it('should hard delete user', async () => {
const { status, body } = await request(app)
.delete(`/users/${userToHardDelete.userId}`)
.send({ force: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: userToHardDelete.userId,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
await utils.waitForWebsocketEvent({ event: 'userDelete', id: userToHardDelete.userId, timeout: 5000 });
});
});
describe('PUT /users', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(`/users`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
for (const key of Object.keys(userDto.admin)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
.put(`/users`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...userDto.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
}
it('should not allow a non-admin to become an admin', async () => {
const { status, body } = await request(app)
.put(`/users`)
.send({ isAdmin: true, id: nonAdmin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.alreadyHasAdmin);
});
it('ignores updates to profileImagePath', async () => {
const { status, body } = await request(app)
.put(`/users`)
.send({ id: admin.userId, profileImagePath: 'invalid.jpg' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: admin.userId, profileImagePath: '' });
});
it('should update first and last name', async () => { it('should update first and last name', async () => {
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/users`) .put(`/users/me`)
.send({ .send({ name: 'Name' })
id: admin.userId,
name: 'Name',
})
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
@ -274,17 +108,13 @@ describe('/users', () => {
updatedAt: expect.any(String), updatedAt: expect.any(String),
name: 'Name', name: 'Name',
}); });
expect(before.updatedAt).not.toEqual(body.updatedAt);
}); });
it('should update memories enabled', async () => { it('should update memories enabled', async () => {
const before = await getUserById({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); const before = await getMyUser({ headers: asBearerAuth(admin.accessToken) });
const { status, body } = await request(app) const { status, body } = await request(app)
.put(`/users`) .put(`/users/me`)
.send({ .send({ memoriesEnabled: false })
id: admin.userId,
memoriesEnabled: false,
})
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
@ -293,7 +123,80 @@ describe('/users', () => {
updatedAt: expect.anything(), updatedAt: expect.anything(),
memoriesEnabled: false, memoriesEnabled: false,
}); });
expect(before.updatedAt).not.toEqual(body.updatedAt);
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) });
expect(user.shouldChangePassword).toBe(true);
const { status, body } = await request(app)
.put(`/users/me`)
.send({ password: 'super-secret' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
email: nonAdmin.userEmail,
shouldChangePassword: false,
});
const token = await login({ loginCredentialDto: { email: nonAdmin.userEmail, password: 'super-secret' } });
expect(token.accessToken).toBeDefined();
});
it('should not allow user to change to a taken email', async () => {
const { status, body } = await request(app)
.put(`/users/me`)
.send({ email: 'admin@immich.cloud' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(400);
expect(body).toMatchObject(errorDto.badRequest('Email already in use by another account'));
});
it('should update my email', async () => {
const before = await getMyUser({ headers: asBearerAuth(nonAdmin.accessToken) });
const { status, body } = await request(app)
.put(`/users/me`)
.send({ email: 'non-admin@immich.cloud' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
...before,
email: 'non-admin@immich.cloud',
updatedAt: expect.anything(),
});
});
});
describe('GET /users/:id', () => {
it('should require authentication', async () => {
const { status } = await request(app).get(`/users/${admin.userId}`);
expect(status).toEqual(401);
});
it('should get the user', async () => {
const { status, body } = await request(app)
.get(`/users/${admin.userId}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
});
expect(body).not.toMatchObject({
shouldChangePassword: expect.anything(),
memoriesEnabled: expect.anything(),
storageLabel: expect.anything(),
});
}); });
}); });
}); });

View file

@ -5,10 +5,10 @@ import {
CreateAlbumDto, CreateAlbumDto,
CreateAssetDto, CreateAssetDto,
CreateLibraryDto, CreateLibraryDto,
CreateUserDto,
MetadataSearchDto, MetadataSearchDto,
PersonCreateDto, PersonCreateDto,
SharedLinkCreateDto, SharedLinkCreateDto,
UserAdminCreateDto,
ValidateLibraryDto, ValidateLibraryDto,
createAlbum, createAlbum,
createApiKey, createApiKey,
@ -16,7 +16,7 @@ import {
createPartner, createPartner,
createPerson, createPerson,
createSharedLink, createSharedLink,
createUser, createUserAdmin,
deleteAssets, deleteAssets,
getAllJobsStatus, getAllJobsStatus,
getAssetInfo, getAssetInfo,
@ -273,8 +273,8 @@ export const utils = {
return response; return response;
}, },
userSetup: async (accessToken: string, dto: CreateUserDto) => { userSetup: async (accessToken: string, dto: UserAdminCreateDto) => {
await createUser({ createUserDto: dto }, { headers: asBearerAuth(accessToken) }); await createUserAdmin({ userAdminCreateDto: dto }, { headers: asBearerAuth(accessToken) });
return login({ return login({
loginCredentialDto: { email: dto.email, password: dto.password }, loginCredentialDto: { email: dto.email, password: dto.password },
}); });

View file

@ -27,7 +27,7 @@ class User {
Id get isarId => fastHash(id); Id get isarId => fastHash(id);
User.fromUserDto(UserResponseDto dto) User.fromUserDto(UserAdminResponseDto dto)
: id = dto.id, : id = dto.id,
updatedAt = dto.updatedAt, updatedAt = dto.updatedAt,
email = dto.email, email = dto.email,
@ -44,21 +44,21 @@ class User {
User.fromPartnerDto(PartnerResponseDto dto) User.fromPartnerDto(PartnerResponseDto dto)
: id = dto.id, : id = dto.id,
updatedAt = dto.updatedAt, updatedAt = DateTime.now(),
email = dto.email, email = dto.email,
name = dto.name, name = dto.name,
isPartnerSharedBy = false, isPartnerSharedBy = false,
isPartnerSharedWith = false, isPartnerSharedWith = false,
profileImagePath = dto.profileImagePath, profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin, isAdmin = false,
memoryEnabled = dto.memoriesEnabled ?? false, memoryEnabled = false,
avatarColor = dto.avatarColor.toAvatarColor(), avatarColor = dto.avatarColor.toAvatarColor(),
inTimeline = dto.inTimeline ?? false, inTimeline = dto.inTimeline ?? false,
quotaUsageInBytes = dto.quotaUsageInBytes ?? 0, quotaUsageInBytes = 0,
quotaSizeInBytes = dto.quotaSizeInBytes ?? 0; quotaSizeInBytes = 0;
/// Base user dto used where the complete user object is not required /// Base user dto used where the complete user object is not required
User.fromSimpleUserDto(UserDto dto) User.fromSimpleUserDto(UserResponseDto dto)
: id = dto.id, : id = dto.id,
email = dto.email, email = dto.email,
name = dto.name, name = dto.name,

View file

@ -138,11 +138,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> changePassword(String newPassword) async { Future<bool> changePassword(String newPassword) async {
try { try {
await _apiService.userApi.updateUser( await _apiService.userApi.updateMyUser(
UpdateUserDto( UserUpdateMeDto(
id: state.userId,
password: newPassword, password: newPassword,
shouldChangePassword: false,
), ),
); );
@ -178,9 +176,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
user = offlineUser; user = offlineUser;
retResult = false; retResult = false;
} else { } else {
UserResponseDto? userResponseDto; UserAdminResponseDto? userResponseDto;
try { try {
userResponseDto = await _apiService.userApi.getMyUserInfo(); userResponseDto = await _apiService.userApi.getMyUser();
} 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]",

View file

@ -20,7 +20,7 @@ class CurrentUserProvider extends StateNotifier<User?> {
refresh() async { refresh() async {
try { try {
final user = await _apiService.userApi.getMyUserInfo(); final user = await _apiService.userApi.getMyUser();
if (user != null) { if (user != null) {
Store.put( Store.put(
StoreKey.currentUser, StoreKey.currentUser,

View file

@ -57,7 +57,7 @@ class TabNavigationObserver extends AutoRouterObserver {
// Update user info // Update user info
try { try {
final userResponseDto = final userResponseDto =
await ref.read(apiServiceProvider).userApi.getMyUserInfo(); await ref.read(apiServiceProvider).userApi.getMyUser();
if (userResponseDto == null) { if (userResponseDto == null) {
return; return;

View file

@ -37,10 +37,10 @@ class UserService {
this._partnerService, this._partnerService,
); );
Future<List<User>?> _getAllUsers({required bool isAll}) async { Future<List<User>?> _getAllUsers() async {
try { try {
final dto = await _apiService.userApi.getAllUsers(isAll); final dto = await _apiService.userApi.searchUsers();
return dto?.map(User.fromUserDto).toList(); return dto?.map(User.fromSimpleUserDto).toList();
} catch (e) { } catch (e) {
_log.warning("Failed get all users", e); _log.warning("Failed get all users", e);
return null; return null;
@ -71,7 +71,7 @@ class UserService {
} }
Future<List<User>?> getUsersFromServer() async { Future<List<User>?> getUsersFromServer() async {
final List<User>? users = await _getAllUsers(isAll: true); final List<User>? users = await _getAllUsers();
final List<User>? sharedBy = final List<User>? sharedBy =
await _partnerService.getPartners(PartnerDirection.sharedBy); await _partnerService.getPartners(PartnerDirection.sharedBy);
final List<User>? sharedWith = final List<User>? sharedWith =

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.

View file

@ -206,6 +206,274 @@
] ]
} }
}, },
"/admin/users": {
"get": {
"operationId": "searchUsersAdmin",
"parameters": [
{
"name": "withDeleted",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/UserAdminResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
},
"post": {
"operationId": "createUserAdmin",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
}
},
"/admin/users/{id}": {
"delete": {
"operationId": "deleteUserAdmin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminDeleteDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
},
"get": {
"operationId": "getUserAdmin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
},
"put": {
"operationId": "updateUserAdmin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminUpdateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
}
},
"/admin/users/{id}/restore": {
"post": {
"operationId": "restoreUserAdmin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
}
},
"/albums": { "/albums": {
"get": { "get": {
"operationId": "getAllAlbums", "operationId": "getAllAlbums",
@ -1879,7 +2147,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UserResponseDto" "$ref": "#/components/schemas/UserAdminResponseDto"
} }
} }
}, },
@ -1910,7 +2178,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UserResponseDto" "$ref": "#/components/schemas/UserAdminResponseDto"
} }
} }
}, },
@ -3160,7 +3428,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UserResponseDto" "$ref": "#/components/schemas/UserAdminResponseDto"
} }
} }
}, },
@ -3206,7 +3474,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UserResponseDto" "$ref": "#/components/schemas/UserAdminResponseDto"
} }
} }
}, },
@ -6030,17 +6298,8 @@
}, },
"/users": { "/users": {
"get": { "get": {
"operationId": "getAllUsers", "operationId": "searchUsers",
"parameters": [ "parameters": [],
{
"name": "isAll",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
@ -6070,26 +6329,18 @@
"tags": [ "tags": [
"User" "User"
] ]
}, }
"post": { },
"operationId": "createUser", "/users/me": {
"get": {
"operationId": "getMyUser",
"parameters": [], "parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserDto"
}
}
},
"required": true
},
"responses": { "responses": {
"201": { "200": {
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UserResponseDto" "$ref": "#/components/schemas/UserAdminResponseDto"
} }
} }
}, },
@ -6112,13 +6363,13 @@
] ]
}, },
"put": { "put": {
"operationId": "updateUser", "operationId": "updateMyUser",
"parameters": [], "parameters": [],
"requestBody": { "requestBody": {
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UpdateUserDto" "$ref": "#/components/schemas/UserUpdateMeDto"
} }
} }
}, },
@ -6129,39 +6380,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/UserResponseDto" "$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
}
},
"/users/me": {
"get": {
"operationId": "getMyUserInfo",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
} }
} }
}, },
@ -6251,58 +6470,8 @@
} }
}, },
"/users/{id}": { "/users/{id}": {
"delete": {
"operationId": "deleteUser",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteUserDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
},
"get": { "get": {
"operationId": "getUserById", "operationId": "getUser",
"parameters": [ "parameters": [
{ {
"name": "id", "name": "id",
@ -6384,48 +6553,6 @@
"User" "User"
] ]
} }
},
"/users/{id}/restore": {
"post": {
"operationId": "restoreUser",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
}
} }
}, },
"info": { "info": {
@ -6567,7 +6694,7 @@
"type": "string" "type": "string"
}, },
"user": { "user": {
"$ref": "#/components/schemas/UserDto" "$ref": "#/components/schemas/UserResponseDto"
} }
}, },
"required": [ "required": [
@ -7775,52 +7902,6 @@
], ],
"type": "object" "type": "object"
}, },
"CreateUserDto": {
"properties": {
"email": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"notify": {
"type": "boolean"
},
"password": {
"type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"minimum": 1,
"nullable": true,
"type": "integer"
},
"shouldChangePassword": {
"type": "boolean"
},
"storageLabel": {
"nullable": true,
"type": "string"
}
},
"required": [
"email",
"name",
"password"
],
"type": "object"
},
"DeleteUserDto": {
"properties": {
"force": {
"type": "boolean"
}
},
"type": "object"
},
"DownloadArchiveInfo": { "DownloadArchiveInfo": {
"properties": { "properties": {
"assetIds": { "assetIds": {
@ -8803,15 +8884,6 @@
"avatarColor": { "avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor" "$ref": "#/components/schemas/UserAvatarColor"
}, },
"createdAt": {
"format": "date-time",
"type": "string"
},
"deletedAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"email": { "email": {
"type": "string" "type": "string"
}, },
@ -8821,62 +8893,19 @@
"inTimeline": { "inTimeline": {
"type": "boolean" "type": "boolean"
}, },
"isAdmin": {
"type": "boolean"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
"oauthId": {
"type": "string"
},
"profileImagePath": { "profileImagePath": {
"type": "string" "type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"quotaUsageInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"shouldChangePassword": {
"type": "boolean"
},
"status": {
"$ref": "#/components/schemas/UserStatus"
},
"storageLabel": {
"nullable": true,
"type": "string"
},
"updatedAt": {
"format": "date-time",
"type": "string"
} }
}, },
"required": [ "required": [
"avatarColor", "avatarColor",
"createdAt",
"deletedAt",
"email", "email",
"id", "id",
"isAdmin",
"name", "name",
"oauthId", "profileImagePath"
"profileImagePath",
"quotaSizeInBytes",
"quotaUsageInBytes",
"shouldChangePassword",
"status",
"storageLabel",
"updatedAt"
], ],
"type": "object" "type": "object"
}, },
@ -10810,48 +10839,6 @@
}, },
"type": "object" "type": "object"
}, },
"UpdateUserDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
"id": {
"format": "uuid",
"type": "string"
},
"isAdmin": {
"type": "boolean"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"password": {
"type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"minimum": 1,
"nullable": true,
"type": "integer"
},
"shouldChangePassword": {
"type": "boolean"
},
"storageLabel": {
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
},
"UsageByUserDto": { "UsageByUserDto": {
"properties": { "properties": {
"photos": { "photos": {
@ -10886,49 +10873,53 @@
], ],
"type": "object" "type": "object"
}, },
"UserAvatarColor": { "UserAdminCreateDto": {
"enum": [
"primary",
"pink",
"red",
"yellow",
"blue",
"green",
"purple",
"orange",
"gray",
"amber"
],
"type": "string"
},
"UserDto": {
"properties": { "properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": { "email": {
"type": "string" "type": "string"
}, },
"id": { "memoriesEnabled": {
"type": "string" "type": "boolean"
}, },
"name": { "name": {
"type": "string" "type": "string"
}, },
"profileImagePath": { "notify": {
"type": "boolean"
},
"password": {
"type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"minimum": 1,
"nullable": true,
"type": "integer"
},
"shouldChangePassword": {
"type": "boolean"
},
"storageLabel": {
"nullable": true,
"type": "string" "type": "string"
} }
}, },
"required": [ "required": [
"avatarColor",
"email", "email",
"id",
"name", "name",
"profileImagePath" "password"
], ],
"type": "object" "type": "object"
}, },
"UserResponseDto": { "UserAdminDeleteDto": {
"properties": {
"force": {
"type": "boolean"
}
},
"type": "object"
},
"UserAdminResponseDto": {
"properties": { "properties": {
"avatarColor": { "avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor" "$ref": "#/components/schemas/UserAvatarColor"
@ -11007,6 +10998,81 @@
], ],
"type": "object" "type": "object"
}, },
"UserAdminUpdateDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"password": {
"type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"minimum": 1,
"nullable": true,
"type": "integer"
},
"shouldChangePassword": {
"type": "boolean"
},
"storageLabel": {
"nullable": true,
"type": "string"
}
},
"type": "object"
},
"UserAvatarColor": {
"enum": [
"primary",
"pink",
"red",
"yellow",
"blue",
"green",
"purple",
"orange",
"gray",
"amber"
],
"type": "string"
},
"UserResponseDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"profileImagePath": {
"type": "string"
}
},
"required": [
"avatarColor",
"email",
"id",
"name",
"profileImagePath"
],
"type": "object"
},
"UserStatus": { "UserStatus": {
"enum": [ "enum": [
"active", "active",
@ -11015,6 +11081,26 @@
], ],
"type": "string" "type": "string"
}, },
"UserUpdateMeDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"password": {
"type": "string"
}
},
"type": "object"
},
"ValidateAccessTokenResponseDto": { "ValidateAccessTokenResponseDto": {
"properties": { "properties": {
"authStatus": { "authStatus": {

View file

@ -13,13 +13,22 @@ 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, getAllAssets, getMyUser, init } from "@immich/sdk";
=======
import { getAllAlbums, getMyUserInfo, 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 assets = await getAllAssets({ take: 1000 });
=======
const user = await getMyUserInfo(); const user = await getMyUserInfo();
>>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2
const albums = await getAllAlbums({}); const albums = await getAllAlbums({});
console.log({ user, albums }); console.log({ user, albums });

View file

@ -14,7 +14,7 @@ const oazapfts = Oazapfts.runtime(defaults);
export const servers = { export const servers = {
server1: "/api" server1: "/api"
}; };
export type UserDto = { export type UserResponseDto = {
avatarColor: UserAvatarColor; avatarColor: UserAvatarColor;
email: string; email: string;
id: string; id: string;
@ -27,7 +27,7 @@ export type ActivityResponseDto = {
createdAt: string; createdAt: string;
id: string; id: string;
"type": Type; "type": Type;
user: UserDto; user: UserResponseDto;
}; };
export type ActivityCreateDto = { export type ActivityCreateDto = {
albumId: string; albumId: string;
@ -38,7 +38,7 @@ export type ActivityCreateDto = {
export type ActivityStatisticsResponseDto = { export type ActivityStatisticsResponseDto = {
comments: number; comments: number;
}; };
export type UserResponseDto = { export type UserAdminResponseDto = {
avatarColor: UserAvatarColor; avatarColor: UserAvatarColor;
createdAt: string; createdAt: string;
deletedAt: string | null; deletedAt: string | null;
@ -56,6 +56,29 @@ export type UserResponseDto = {
storageLabel: string | null; storageLabel: string | null;
updatedAt: string; updatedAt: string;
}; };
export type UserAdminCreateDto = {
email: string;
memoriesEnabled?: boolean;
name: string;
notify?: boolean;
password: string;
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string | null;
};
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 AlbumUserResponseDto = { export type AlbumUserResponseDto = {
role: AlbumUserRole; role: AlbumUserRole;
user: UserResponseDto; user: UserResponseDto;
@ -517,22 +540,11 @@ export type OAuthCallbackDto = {
}; };
export type PartnerResponseDto = { export type PartnerResponseDto = {
avatarColor: UserAvatarColor; avatarColor: UserAvatarColor;
createdAt: string;
deletedAt: string | null;
email: string; email: string;
id: string; id: string;
inTimeline?: boolean; inTimeline?: boolean;
isAdmin: boolean;
memoriesEnabled?: boolean;
name: string; name: string;
oauthId: string;
profileImagePath: string; profileImagePath: string;
quotaSizeInBytes: number | null;
quotaUsageInBytes: number | null;
shouldChangePassword: boolean;
status: UserStatus;
storageLabel: string | null;
updatedAt: string;
}; };
export type UpdatePartnerDto = { export type UpdatePartnerDto = {
inTimeline: boolean; inTimeline: boolean;
@ -1060,27 +1072,12 @@ export type TimeBucketResponseDto = {
count: number; count: number;
timeBucket: string; timeBucket: string;
}; };
export type CreateUserDto = { export type UserUpdateMeDto = {
email: string;
memoriesEnabled?: boolean;
name: string;
notify?: boolean;
password: string;
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string | null;
};
export type UpdateUserDto = {
avatarColor?: UserAvatarColor; avatarColor?: UserAvatarColor;
email?: string; email?: string;
id: string;
isAdmin?: boolean;
memoriesEnabled?: boolean; memoriesEnabled?: boolean;
name?: string; name?: string;
password?: string; password?: string;
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string;
}; };
export type CreateProfileImageDto = { export type CreateProfileImageDto = {
file: Blob; file: Blob;
@ -1089,9 +1086,6 @@ export type CreateProfileImageResponseDto = {
profileImagePath: string; profileImagePath: string;
userId: string; userId: string;
}; };
export type DeleteUserDto = {
force?: boolean;
};
export function getActivities({ albumId, assetId, level, $type, userId }: { export function getActivities({ albumId, assetId, level, $type, userId }: {
albumId: string; albumId: string;
assetId?: string; assetId?: string;
@ -1146,6 +1140,77 @@ export function deleteActivity({ id }: {
method: "DELETE" method: "DELETE"
})); }));
} }
export function searchUsersAdmin({ withDeleted }: {
withDeleted?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto[];
}>(`/admin/users${QS.query(QS.explode({
withDeleted
}))}`, {
...opts
}));
}
export function createUserAdmin({ userAdminCreateDto }: {
userAdminCreateDto: UserAdminCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: UserAdminResponseDto;
}>("/admin/users", oazapfts.json({
...opts,
method: "POST",
body: userAdminCreateDto
})));
}
export function deleteUserAdmin({ id, userAdminDeleteDto }: {
id: string;
userAdminDeleteDto: UserAdminDeleteDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto;
}>(`/admin/users/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "DELETE",
body: userAdminDeleteDto
})));
}
export function getUserAdmin({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto;
}>(`/admin/users/${encodeURIComponent(id)}`, {
...opts
}));
}
export function updateUserAdmin({ id, userAdminUpdateDto }: {
id: string;
userAdminUpdateDto: UserAdminUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto;
}>(`/admin/users/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PUT",
body: userAdminUpdateDto
})));
}
export function restoreUserAdmin({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: UserAdminResponseDto;
}>(`/admin/users/${encodeURIComponent(id)}/restore`, {
...opts,
method: "POST"
}));
}
export function getAllAlbums({ assetId, shared }: { export function getAllAlbums({ assetId, shared }: {
assetId?: string; assetId?: string;
shared?: boolean; shared?: boolean;
@ -1589,7 +1654,7 @@ export function signUpAdmin({ signUpDto }: {
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 201; status: 201;
data: UserResponseDto; data: UserAdminResponseDto;
}>("/auth/admin-sign-up", oazapfts.json({ }>("/auth/admin-sign-up", oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
@ -1601,7 +1666,7 @@ export function changePassword({ changePasswordDto }: {
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: UserResponseDto; data: UserAdminResponseDto;
}>("/auth/change-password", oazapfts.json({ }>("/auth/change-password", oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
@ -1934,7 +1999,7 @@ export function linkOAuthAccount({ oAuthCallbackDto }: {
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 201; status: 201;
data: UserResponseDto; data: UserAdminResponseDto;
}>("/oauth/link", oazapfts.json({ }>("/oauth/link", oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
@ -1949,7 +2014,7 @@ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) {
export function unlinkOAuthAccount(opts?: Oazapfts.RequestOpts) { export function unlinkOAuthAccount(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 201; status: 201;
data: UserResponseDto; data: UserAdminResponseDto;
}>("/oauth/unlink", { }>("/oauth/unlink", {
...opts, ...opts,
method: "POST" method: "POST"
@ -2687,50 +2752,34 @@ export function restoreAssets({ bulkIdsDto }: {
body: bulkIdsDto body: bulkIdsDto
}))); })));
} }
export function getAllUsers({ isAll }: { export function searchUsers(opts?: Oazapfts.RequestOpts) {
isAll: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: UserResponseDto[]; data: UserResponseDto[];
}>(`/users${QS.query(QS.explode({ }>("/users", {
isAll
}))}`, {
...opts ...opts
})); }));
} }
export function createUser({ createUserDto }: { export function getMyUser(opts?: Oazapfts.RequestOpts) {
createUserDto: CreateUserDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: UserResponseDto;
}>("/users", oazapfts.json({
...opts,
method: "POST",
body: createUserDto
})));
}
export function updateUser({ updateUserDto }: {
updateUserDto: UpdateUserDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: UserResponseDto; data: UserAdminResponseDto;
}>("/users", oazapfts.json({
...opts,
method: "PUT",
body: updateUserDto
})));
}
export function getMyUserInfo(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto;
}>("/users/me", { }>("/users/me", {
...opts ...opts
})); }));
} }
export function updateMyUser({ userUpdateMeDto }: {
userUpdateMeDto: UserUpdateMeDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto;
}>("/users/me", oazapfts.json({
...opts,
method: "PUT",
body: userUpdateMeDto
})));
}
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,
@ -2749,20 +2798,7 @@ export function createProfileImage({ createProfileImageDto }: {
body: createProfileImageDto body: createProfileImageDto
}))); })));
} }
export function deleteUser({ id, deleteUserDto }: { export function getUser({ id }: {
id: string;
deleteUserDto: DeleteUserDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto;
}>(`/users/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "DELETE",
body: deleteUserDto
})));
}
export function getUserById({ id }: {
id: string; id: string;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
@ -2782,17 +2818,6 @@ export function getProfileImage({ id }: {
...opts ...opts
})); }));
} }
export function restoreUser({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: UserResponseDto;
}>(`/users/${encodeURIComponent(id)}/restore`, {
...opts,
method: "POST"
}));
}
export enum ReactionLevel { export enum ReactionLevel {
Album = "album", Album = "album",
Asset = "asset" Asset = "asset"
@ -2817,15 +2842,15 @@ export enum UserAvatarColor {
Gray = "gray", Gray = "gray",
Amber = "amber" Amber = "amber"
} }
export enum AlbumUserRole {
Editor = "editor",
Viewer = "viewer"
}
export enum UserStatus { export enum UserStatus {
Active = "active", Active = "active",
Removing = "removing", Removing = "removing",
Deleted = "deleted" Deleted = "deleted"
} }
export enum AlbumUserRole {
Editor = "editor",
Viewer = "viewer"
}
export enum TagTypeEnum { export enum TagTypeEnum {
Object = "OBJECT", Object = "OBJECT",
Face = "FACE", Face = "FACE",

View file

@ -1,9 +1,9 @@
import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander'; import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander';
import { UserResponseDto } from 'src/dtos/user.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { CliService } from 'src/services/cli.service'; import { CliService } from 'src/services/cli.service';
const prompt = (inquirer: InquirerService) => { const prompt = (inquirer: InquirerService) => {
return function ask(admin: UserResponseDto) { return function ask(admin: UserAdminResponseDto) {
const { id, oauthId, email, name } = admin; const { id, oauthId, email, name } = admin;
console.log(`Found Admin: console.log(`Found Admin:
- ID=${id} - ID=${id}

View file

@ -12,7 +12,7 @@ import {
SignUpDto, SignUpDto,
ValidateAccessTokenResponseDto, ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response'; import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
@ -40,7 +40,7 @@ export class AuthController {
} }
@Post('admin-sign-up') @Post('admin-sign-up')
signUpAdmin(@Body() dto: SignUpDto): Promise<UserResponseDto> { signUpAdmin(@Body() dto: SignUpDto): Promise<UserAdminResponseDto> {
return this.service.adminSignUp(dto); return this.service.adminSignUp(dto);
} }
@ -54,8 +54,8 @@ export class AuthController {
@Post('change-password') @Post('change-password')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Authenticated() @Authenticated()
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> { changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
return this.service.changePassword(auth, dto).then(mapUser); return this.service.changePassword(auth, dto);
} }
@Post('logout') @Post('logout')

View file

@ -27,6 +27,7 @@ import { SystemMetadataController } from 'src/controllers/system-metadata.contro
import { TagController } from 'src/controllers/tag.controller'; import { TagController } from 'src/controllers/tag.controller';
import { TimelineController } from 'src/controllers/timeline.controller'; import { TimelineController } from 'src/controllers/timeline.controller';
import { TrashController } from 'src/controllers/trash.controller'; import { TrashController } from 'src/controllers/trash.controller';
import { UserAdminController } from 'src/controllers/user-admin.controller';
import { UserController } from 'src/controllers/user.controller'; import { UserController } from 'src/controllers/user.controller';
export const controllers = [ export const controllers = [
@ -59,5 +60,6 @@ export const controllers = [
TagController, TagController,
TimelineController, TimelineController,
TrashController, TrashController,
UserAdminController,
UserController, UserController,
]; ];

View file

@ -10,7 +10,7 @@ import {
OAuthCallbackDto, OAuthCallbackDto,
OAuthConfigDto, OAuthConfigDto,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
import { UserResponseDto } from 'src/dtos/user.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response'; import { respondWithCookie } from 'src/utils/response';
@ -53,13 +53,13 @@ export class OAuthController {
@Post('link') @Post('link')
@Authenticated() @Authenticated()
linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise<UserResponseDto> { linkOAuthAccount(@Auth() auth: AuthDto, @Body() dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
return this.service.link(auth, dto); return this.service.link(auth, dto);
} }
@Post('unlink') @Post('unlink')
@Authenticated() @Authenticated()
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserResponseDto> { unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.unlink(auth); return this.service.unlink(auth);
} }
} }

View file

@ -0,0 +1,63 @@
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 {
UserAdminCreateDto,
UserAdminDeleteDto,
UserAdminResponseDto,
UserAdminSearchDto,
UserAdminUpdateDto,
} from 'src/dtos/user.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { UserAdminService } from 'src/services/user-admin.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('User')
@Controller('admin/users')
export class UserAdminController {
constructor(private service: UserAdminService) {}
@Get()
@Authenticated({ admin: true })
searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
@Authenticated({ admin: true })
createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
return this.service.create(createUserDto);
}
@Get(':id')
@Authenticated({ admin: true })
getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ admin: true })
updateUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UserAdminUpdateDto,
): Promise<UserAdminResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ admin: true })
deleteUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UserAdminDeleteDto,
): Promise<UserAdminResponseDto> {
return this.service.delete(auth, id, dto);
}
@Post(':id/restore')
@Authenticated({ admin: true })
restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.restore(auth, id);
}
}

View file

@ -10,7 +10,6 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query,
Res, Res,
UploadedFile, UploadedFile,
UseInterceptors, UseInterceptors,
@ -19,7 +18,7 @@ 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 { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto } 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';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor'; import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor';
@ -37,58 +36,28 @@ export class UserController {
@Get() @Get()
@Authenticated() @Authenticated()
getAllUsers(@Auth() auth: AuthDto, @Query('isAll') isAll: boolean): Promise<UserResponseDto[]> { searchUsers(): Promise<UserResponseDto[]> {
return this.service.getAll(auth, isAll); return this.service.search();
}
@Post()
@Authenticated({ admin: true })
createUser(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.service.create(createUserDto);
} }
@Get('me') @Get('me')
@Authenticated() @Authenticated()
getMyUserInfo(@Auth() auth: AuthDto): Promise<UserResponseDto> { getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto {
return this.service.getMe(auth); return this.service.getMe(auth);
} }
@Put('me')
@Authenticated()
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
return this.service.updateMe(auth, dto);
}
@Get(':id') @Get(':id')
@Authenticated() @Authenticated()
getUserById(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> { getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.get(id); return this.service.get(id);
} }
@Delete('profile-image')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteProfileImage(auth);
}
@Delete(':id')
@Authenticated({ admin: true })
deleteUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: DeleteUserDto,
): Promise<UserResponseDto> {
return this.service.delete(auth, id, dto);
}
@Post(':id/restore')
@Authenticated({ admin: true })
restoreUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.restore(auth, id);
}
// TODO: replace with @Put(':id')
@Put()
@Authenticated()
updateUser(@Auth() auth: AuthDto, @Body() updateUserDto: UpdateUserDto): Promise<UserResponseDto> {
return this.service.update(auth, updateUserDto);
}
@UseInterceptors(FileUploadInterceptor) @UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto }) @ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@ -101,6 +70,13 @@ export class UserController {
return this.service.createProfileImage(auth, fileInfo); return this.service.createProfileImage(auth, fileInfo);
} }
@Delete('profile-image')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteProfileImage(auth);
}
@Get(':id/profile-image') @Get(':id/profile-image')
@FileResponse() @FileResponse()
@Authenticated() @Authenticated()

View file

@ -1,7 +1,6 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { UserResponseDto } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
@ -26,46 +25,6 @@ export class UserCore {
instance = null; instance = null;
} }
// TODO: move auth related checks to the service layer
async updateUser(user: UserEntity | UserResponseDto, id: string, dto: Partial<UserEntity>): Promise<UserEntity> {
if (!user.isAdmin && user.id !== id) {
throw new ForbiddenException('You are not allowed to update this user');
}
if (!user.isAdmin) {
// Users can never update the isAdmin property.
delete dto.isAdmin;
delete dto.storageLabel;
} else if (dto.isAdmin && user.id !== id) {
// Admin cannot create another admin.
throw new BadRequestException('The server already has an admin');
}
if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== id) {
throw new BadRequestException('Email already in use by another account');
}
}
if (dto.storageLabel) {
const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel);
if (duplicate && duplicate.id !== id) {
throw new BadRequestException('Storage label already in use by another account');
}
}
if (dto.password) {
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
}
if (dto.storageLabel === '') {
dto.storageLabel = null;
}
return this.userRepository.update(id, { ...dto, updatedAt: new Date() });
}
async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> { async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> {
const user = await this.userRepository.getByEmail(dto.email); const user = await this.userRepository.getByEmail(dto.email);
if (user) { if (user) {

View file

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
import { UserDto, mapSimpleUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { ActivityEntity } from 'src/entities/activity.entity'; import { ActivityEntity } from 'src/entities/activity.entity';
import { Optional, ValidateUUID } from 'src/validation'; import { Optional, ValidateUUID } from 'src/validation';
@ -20,7 +20,7 @@ export class ActivityResponseDto {
id!: string; id!: string;
createdAt!: Date; createdAt!: Date;
type!: ReactionType; type!: ReactionType;
user!: UserDto; user!: UserResponseDto;
assetId!: string | null; assetId!: string | null;
comment?: string | null; comment?: string | null;
} }
@ -73,6 +73,6 @@ export function mapActivity(activity: ActivityEntity): ActivityResponseDto {
createdAt: activity.createdAt, createdAt: activity.createdAt,
comment: activity.comment, comment: activity.comment,
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT, type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
user: mapSimpleUser(activity.user), user: mapUser(activity.user),
}; };
} }

View file

@ -1,12 +1,12 @@
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { CreateUserDto, CreateUserOAuthDto, UpdateUserDto } from 'src/dtos/user.dto'; import { UserAdminCreateDto, UserUpdateMeDto } from 'src/dtos/user.dto';
describe('update user DTO', () => { describe('update user DTO', () => {
it('should allow emails without a tld', async () => { it('should allow emails without a tld', async () => {
const someEmail = 'test@test'; const someEmail = 'test@test';
const dto = plainToInstance(UpdateUserDto, { const dto = plainToInstance(UserUpdateMeDto, {
email: someEmail, email: someEmail,
id: '3fe388e4-2078-44d7-b36c-39d9dee3a657', id: '3fe388e4-2078-44d7-b36c-39d9dee3a657',
}); });
@ -18,22 +18,22 @@ describe('update user DTO', () => {
describe('create user DTO', () => { describe('create user DTO', () => {
it('validates the email', async () => { it('validates the email', async () => {
const params: Partial<CreateUserDto> = { const params: Partial<UserAdminCreateDto> = {
email: undefined, email: undefined,
password: 'password', password: 'password',
name: 'name', name: 'name',
}; };
let dto: CreateUserDto = plainToInstance(CreateUserDto, params); let dto: UserAdminCreateDto = plainToInstance(UserAdminCreateDto, params);
let errors = await validate(dto); let errors = await validate(dto);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
params.email = 'invalid email'; params.email = 'invalid email';
dto = plainToInstance(CreateUserDto, params); dto = plainToInstance(UserAdminCreateDto, params);
errors = await validate(dto); errors = await validate(dto);
expect(errors).toHaveLength(1); expect(errors).toHaveLength(1);
params.email = 'valid@email.com'; params.email = 'valid@email.com';
dto = plainToInstance(CreateUserDto, params); dto = plainToInstance(UserAdminCreateDto, params);
errors = await validate(dto); errors = await validate(dto);
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
}); });
@ -41,7 +41,7 @@ describe('create user DTO', () => {
it('should allow emails without a tld', async () => { it('should allow emails without a tld', async () => {
const someEmail = 'test@test'; const someEmail = 'test@test';
const dto = plainToInstance(CreateUserDto, { const dto = plainToInstance(UserAdminCreateDto, {
email: someEmail, email: someEmail,
password: 'some password', password: 'some password',
name: 'some name', name: 'some name',
@ -51,18 +51,3 @@ describe('create user DTO', () => {
expect(dto.email).toEqual(someEmail); expect(dto.email).toEqual(someEmail);
}); });
}); });
describe('create user oauth DTO', () => {
it('should allow emails without a tld', async () => {
const someEmail = 'test@test';
const dto = plainToInstance(CreateUserOAuthDto, {
email: someEmail,
oauthId: 'some oauth id',
name: 'some name',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.email).toEqual(someEmail);
});
});

View file

@ -1,12 +1,63 @@
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, IsUUID } from 'class-validator'; import { IsBoolean, IsEmail, IsEnum, 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';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
export class CreateUserDto { export class UserUpdateMeDto {
@Optional()
@IsEmail({ require_tld: false })
@Transform(toEmail)
email?: string;
// TODO: migrate to the other change password endpoint
@Optional()
@IsNotEmpty()
@IsString()
password?: string;
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
@ValidateBoolean({ optional: true })
memoriesEnabled?: boolean;
@Optional()
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor;
}
export class UserResponseDto {
id!: string;
name!: string;
email!: string;
profileImagePath!: string;
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor!: UserAvatarColor;
}
export const mapUser = (entity: UserEntity): UserResponseDto => {
return {
id: entity.id,
email: entity.email,
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity).avatar.color,
};
};
export class UserAdminSearchDto {
@ValidateBoolean({ optional: true })
withDeleted?: boolean;
}
export class UserAdminCreateDto {
@IsEmail({ require_tld: false }) @IsEmail({ require_tld: false })
@Transform(toEmail) @Transform(toEmail)
email!: string; email!: string;
@ -41,23 +92,7 @@ export class CreateUserDto {
notify?: boolean; notify?: boolean;
} }
export class CreateUserOAuthDto { export class UserAdminUpdateDto {
@IsEmail({ require_tld: false })
@Transform(({ value }) => value?.toLowerCase())
email!: string;
@IsNotEmpty()
oauthId!: string;
name?: string;
}
export class DeleteUserDto {
@ValidateBoolean({ optional: true })
force?: boolean;
}
export class UpdateUserDto {
@Optional() @Optional()
@IsEmail({ require_tld: false }) @IsEmail({ require_tld: false })
@Transform(toEmail) @Transform(toEmail)
@ -73,18 +108,10 @@ export class UpdateUserDto {
@IsNotEmpty() @IsNotEmpty()
name?: string; name?: string;
@Optional() @Optional({ nullable: true })
@IsString() @IsString()
@Transform(toSanitized) @Transform(toSanitized)
storageLabel?: string; storageLabel?: string | null;
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
id!: string;
@ValidateBoolean({ optional: true })
isAdmin?: boolean;
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
shouldChangePassword?: boolean; shouldChangePassword?: boolean;
@ -104,17 +131,12 @@ export class UpdateUserDto {
quotaSizeInBytes?: number | null; quotaSizeInBytes?: number | null;
} }
export class UserDto { export class UserAdminDeleteDto {
id!: string; @ValidateBoolean({ optional: true })
name!: string; force?: boolean;
email!: string;
profileImagePath!: string;
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor!: UserAvatarColor;
} }
export class UserResponseDto extends UserDto { export class UserAdminResponseDto extends UserResponseDto {
storageLabel!: string | null; storageLabel!: string | null;
shouldChangePassword!: boolean; shouldChangePassword!: boolean;
isAdmin!: boolean; isAdmin!: boolean;
@ -131,19 +153,9 @@ export class UserResponseDto extends UserDto {
status!: string; status!: string;
} }
export const mapSimpleUser = (entity: UserEntity): UserDto => { export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto {
return { return {
id: entity.id, ...mapUser(entity),
email: entity.email,
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity).avatar.color,
};
};
export function mapUser(entity: UserEntity): UserResponseDto {
return {
...mapSimpleUser(entity),
storageLabel: entity.storageLabel, storageLabel: entity.storageLabel,
shouldChangePassword: entity.shouldChangePassword, shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin, isAdmin: entity.isAdmin,

View file

@ -22,13 +22,17 @@ FROM
"APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status", "APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status",
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt", "APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
"APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes", "APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes",
"APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes" "APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes",
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_userId",
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."key" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_key",
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."value" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_value"
FROM FROM
"api_keys" "APIKeyEntity" "api_keys" "APIKeyEntity"
LEFT JOIN "users" "APIKeyEntity__APIKeyEntity_user" ON "APIKeyEntity__APIKeyEntity_user"."id" = "APIKeyEntity"."userId" LEFT JOIN "users" "APIKeyEntity__APIKeyEntity_user" ON "APIKeyEntity__APIKeyEntity_user"."id" = "APIKeyEntity"."userId"
AND ( AND (
"APIKeyEntity__APIKeyEntity_user"."deletedAt" IS NULL "APIKeyEntity__APIKeyEntity_user"."deletedAt" IS NULL
) )
LEFT JOIN "user_metadata" "7f5f7a38bf327bfbbf826778460704c9a50fe6f4" ON "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" = "APIKeyEntity__APIKeyEntity_user"."id"
WHERE WHERE
(("APIKeyEntity"."key" = $1)) (("APIKeyEntity"."key" = $1))
) "distinctAlias" ) "distinctAlias"

View file

@ -38,13 +38,17 @@ FROM
"SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status", "SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status",
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt", "SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt",
"SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes", "SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes",
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes" "SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes",
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId",
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key",
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value"
FROM FROM
"sessions" "SessionEntity" "sessions" "SessionEntity"
LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId" LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId"
AND ( AND (
"SessionEntity__SessionEntity_user"."deletedAt" IS NULL "SessionEntity__SessionEntity_user"."deletedAt" IS NULL
) )
LEFT JOIN "user_metadata" "469e6aa7ff79eff78f8441f91ba15bb07d3634dd" ON "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" = "SessionEntity__SessionEntity_user"."id"
WHERE WHERE
(("SessionEntity"."token" = $1)) (("SessionEntity"."token" = $1))
) "distinctAlias" ) "distinctAlias"

View file

@ -34,7 +34,9 @@ export class ApiKeyRepository implements IKeyRepository {
}, },
where: { key: hashedToken }, where: { key: hashedToken },
relations: { relations: {
user: true, user: {
metadata: true,
},
}, },
}); });
} }

View file

@ -18,7 +18,14 @@ export class SessionRepository implements ISessionRepository {
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string): Promise<SessionEntity | null> { getByToken(token: string): Promise<SessionEntity | null> {
return this.repository.findOne({ where: { token }, relations: { user: true } }); return this.repository.findOne({
where: { token },
relations: {
user: {
metadata: true,
},
},
});
} }
getByUserId(userId: string): Promise<SessionEntity[]> { getByUserId(userId: string): Promise<SessionEntity[]> {

View file

@ -138,6 +138,7 @@ describe('AuthService', () => {
email: 'test@immich.com', email: 'test@immich.com',
password: 'hash-password', password: 'hash-password',
} as UserEntity); } as UserEntity);
userMock.update.mockResolvedValue(userStub.user1);
await sut.changePassword(auth, dto); await sut.changePassword(auth, dto);

View file

@ -11,7 +11,7 @@ import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { AuthType, LOGIN_URL, MOBILE_REDIRECT } from 'src/constants'; import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core'; import { UserCore } from 'src/cores/user.core';
import { import {
@ -27,7 +27,7 @@ import {
SignUpDto, SignUpDto,
mapLoginResponse, mapLoginResponse,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
@ -109,7 +109,7 @@ export class AuthService {
}; };
} }
async changePassword(auth: AuthDto, dto: ChangePasswordDto) { async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
const { password, newPassword } = dto; const { password, newPassword } = dto;
const user = await this.userRepository.getByEmail(auth.user.email, true); const user = await this.userRepository.getByEmail(auth.user.email, true);
if (!user) { if (!user) {
@ -121,10 +121,14 @@ export class AuthService {
throw new BadRequestException('Wrong password'); throw new BadRequestException('Wrong password');
} }
return this.userCore.updateUser(auth.user, auth.user.id, { password: newPassword }); const hashedPassword = await this.cryptoRepository.hashBcrypt(newPassword, SALT_ROUNDS);
const updatedUser = await this.userRepository.update(user.id, { password: hashedPassword });
return mapUserAdmin(updatedUser);
} }
async adminSignUp(dto: SignUpDto): Promise<UserResponseDto> { async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
const adminUser = await this.userRepository.getAdmin(); const adminUser = await this.userRepository.getAdmin();
if (adminUser) { if (adminUser) {
throw new BadRequestException('The server already has an admin'); throw new BadRequestException('The server already has an admin');
@ -138,7 +142,7 @@ export class AuthService {
storageLabel: 'admin', storageLabel: 'admin',
}); });
return mapUser(admin); return mapUserAdmin(admin);
} }
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> { async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
@ -237,7 +241,7 @@ export class AuthService {
return this.createLoginResponse(user, loginDetails); return this.createLoginResponse(user, loginDetails);
} }
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> { async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
const config = await this.configCore.getConfig(); const config = await this.configCore.getConfig();
const { sub: oauthId } = await this.getOAuthProfile(config, dto.url); const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
const duplicate = await this.userRepository.getByOAuthId(oauthId); const duplicate = await this.userRepository.getByOAuthId(oauthId);
@ -245,11 +249,14 @@ export class AuthService {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`); this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.'); throw new BadRequestException('This OAuth account has already been linked to another user.');
} }
return mapUser(await this.userRepository.update(auth.user.id, { oauthId }));
const user = await this.userRepository.update(auth.user.id, { oauthId });
return mapUserAdmin(user);
} }
async unlink(auth: AuthDto): Promise<UserResponseDto> { async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
return mapUser(await this.userRepository.update(auth.user.id, { oauthId: '' })); const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
return mapUserAdmin(user);
} }
private async getLogoutEndpoint(authType: AuthType): Promise<string> { private async getLogoutEndpoint(authType: AuthType): Promise<string> {

View file

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@ -10,7 +10,6 @@ import { IUserRepository } from 'src/interfaces/user.interface';
@Injectable() @Injectable()
export class CliService { export class CliService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private userCore: UserCore;
constructor( constructor(
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@ -18,26 +17,26 @@ export class CliService {
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.userCore = UserCore.create(cryptoRepository, userRepository);
this.logger.setContext(CliService.name); this.logger.setContext(CliService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async listUsers(): Promise<UserResponseDto[]> { async listUsers(): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true }); const users = await this.userRepository.getList({ withDeleted: true });
return users.map((user) => mapUser(user)); return users.map((user) => mapUserAdmin(user));
} }
async resetAdminPassword(ask: (admin: UserResponseDto) => Promise<string | undefined>) { async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
const admin = await this.userRepository.getAdmin(); const admin = await this.userRepository.getAdmin();
if (!admin) { if (!admin) {
throw new Error('Admin account does not exist'); throw new Error('Admin account does not exist');
} }
const providedPassword = await ask(mapUser(admin)); const providedPassword = await ask(mapUserAdmin(admin));
const password = providedPassword || this.cryptoRepository.newPassword(24); const password = providedPassword || this.cryptoRepository.newPassword(24);
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
await this.userCore.updateUser(admin, admin.id, { password }); await this.userRepository.update(admin.id, { password: hashedPassword });
return { admin, password, provided: !!providedPassword }; return { admin, password, provided: !!providedPassword };
} }

View file

@ -33,6 +33,7 @@ import { SystemMetadataService } from 'src/services/system-metadata.service';
import { TagService } from 'src/services/tag.service'; import { TagService } from 'src/services/tag.service';
import { TimelineService } from 'src/services/timeline.service'; import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service'; import { TrashService } from 'src/services/trash.service';
import { UserAdminService } from 'src/services/user-admin.service';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service'; import { VersionService } from 'src/services/version.service';
@ -73,5 +74,6 @@ export const services = [
TimelineService, TimelineService,
TrashService, TrashService,
UserService, UserService,
UserAdminService,
VersionService, VersionService,
]; ];

View file

@ -1,6 +1,4 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { PartnerResponseDto } from 'src/dtos/partner.dto';
import { UserAvatarColor } from 'src/entities/user-metadata.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface'; import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface';
import { PartnerService } from 'src/services/partner.service'; import { PartnerService } from 'src/services/partner.service';
@ -9,45 +7,6 @@ import { partnerStub } from 'test/fixtures/partner.stub';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
const responseDto = {
admin: <PartnerResponseDto>{
email: 'admin@test.com',
name: 'admin_name',
id: 'admin_id',
isAdmin: true,
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
storageLabel: 'admin',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
memoriesEnabled: true,
avatarColor: UserAvatarColor.GRAY,
quotaSizeInBytes: null,
inTimeline: true,
quotaUsageInBytes: 0,
},
user1: <PartnerResponseDto>{
email: 'immich@test.com',
name: 'immich_name',
id: 'user-id',
isAdmin: false,
oauthId: '',
profileImagePath: '',
shouldChangePassword: false,
storageLabel: null,
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
inTimeline: true,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
};
describe(PartnerService.name, () => { describe(PartnerService.name, () => {
let sut: PartnerService; let sut: PartnerService;
let partnerMock: Mocked<IPartnerRepository>; let partnerMock: Mocked<IPartnerRepository>;
@ -65,13 +24,13 @@ describe(PartnerService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it("should return a list of partners with whom I've shared my library", async () => { it("should return a list of partners with whom I've shared my library", async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toEqual([responseDto.admin]); await expect(sut.getAll(authStub.user1, PartnerDirection.SharedBy)).resolves.toBeDefined();
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
}); });
it('should return a list of partners who have shared their libraries with me', async () => { it('should return a list of partners who have shared their libraries with me', async () => {
partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]); partnerMock.getAll.mockResolvedValue([partnerStub.adminToUser1, partnerStub.user1ToAdmin1]);
await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toEqual([responseDto.admin]); await expect(sut.getAll(authStub.user1, PartnerDirection.SharedWith)).resolves.toBeDefined();
expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id); expect(partnerMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
}); });
}); });
@ -81,7 +40,7 @@ describe(PartnerService.name, () => {
partnerMock.get.mockResolvedValue(null); partnerMock.get.mockResolvedValue(null);
partnerMock.create.mockResolvedValue(partnerStub.adminToUser1); partnerMock.create.mockResolvedValue(partnerStub.adminToUser1);
await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toEqual(responseDto.user1); await expect(sut.create(authStub.admin, authStub.user1.user.id)).resolves.toBeDefined();
expect(partnerMock.create).toHaveBeenCalledWith({ expect(partnerMock.create).toHaveBeenCalledWith({
sharedById: authStub.admin.user.id, sharedById: authStub.admin.user.id,

View file

@ -25,7 +25,7 @@ export class PartnerService {
} }
const partner = await this.repository.create(partnerId); const partner = await this.repository.create(partnerId);
return this.mapToPartnerEntity(partner, PartnerDirection.SharedBy); return this.mapPartner(partner, PartnerDirection.SharedBy);
} }
async remove(auth: AuthDto, sharedWithId: string): Promise<void> { async remove(auth: AuthDto, sharedWithId: string): Promise<void> {
@ -44,7 +44,7 @@ export class PartnerService {
return partners return partners
.filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users .filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users
.filter((partner) => partner[key] === auth.user.id) .filter((partner) => partner[key] === auth.user.id)
.map((partner) => this.mapToPartnerEntity(partner, direction)); .map((partner) => this.mapPartner(partner, direction));
} }
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> { async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
@ -52,10 +52,10 @@ export class PartnerService {
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline }); const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline });
return this.mapToPartnerEntity(entity, PartnerDirection.SharedWith); return this.mapPartner(entity, PartnerDirection.SharedWith);
} }
private mapToPartnerEntity(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto { private mapPartner(partner: PartnerEntity, direction: PartnerDirection): PartnerResponseDto {
// this is opposite to return the non-me user of the "partner" // this is opposite to return the non-me user of the "partner"
const user = mapUser( const user = mapUser(
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy, direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,

View file

@ -0,0 +1,197 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { mapUserAdmin } from 'src/dtos/user.dto';
import { UserStatus } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { UserAdminService } from 'src/services/user-admin.service';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, describe } from 'vitest';
describe(UserAdminService.name, () => {
let sut: UserAdminService;
let userMock: Mocked<IUserRepository>;
let cryptoRepositoryMock: Mocked<ICryptoRepository>;
let albumMock: Mocked<IAlbumRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
cryptoRepositoryMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new UserAdminService(albumMock, cryptoRepositoryMock, jobMock, userMock, loggerMock);
userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null),
);
});
describe('create', () => {
it('should not create a user if there is no local admin account', async () => {
userMock.getAdmin.mockResolvedValueOnce(null);
await expect(
sut.create({
email: 'john_smith@email.com',
name: 'John Smith',
password: 'password',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create user', async () => {
userMock.getAdmin.mockResolvedValue(userStub.admin);
userMock.create.mockResolvedValue(userStub.user1);
await expect(
sut.create({
email: userStub.user1.email,
name: userStub.user1.name,
password: 'password',
storageLabel: 'label',
}),
).resolves.toEqual(mapUserAdmin(userStub.user1));
expect(userMock.getAdmin).toBeCalled();
expect(userMock.create).toBeCalledWith({
email: userStub.user1.email,
name: userStub.user1.name,
storageLabel: 'label',
password: expect.anything(),
});
});
});
describe('update', () => {
it('should update the user', async () => {
const update = {
shouldChangePassword: true,
email: 'immich@test.com',
storageLabel: 'storage_label',
};
userMock.getByEmail.mockResolvedValue(null);
userMock.getByStorageLabel.mockResolvedValue(null);
userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.user1, userStub.user1.id, update);
expect(userMock.getByEmail).toHaveBeenCalledWith(update.email);
expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel);
});
it('should not set an empty string for storage label', async () => {
userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.admin, userStub.user1.id, { storageLabel: '' });
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
storageLabel: null,
updatedAt: expect.any(Date),
});
});
it('should not change an email to one already in use', async () => {
const dto = { id: userStub.user1.id, email: 'updated@test.com' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.getByEmail.mockResolvedValue(userStub.admin);
await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('should not let the admin change the storage label to one already in use', async () => {
const dto = { id: userStub.user1.id, storageLabel: 'admin' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.getByStorageLabel.mockResolvedValue(userStub.admin);
await expect(sut.update(authStub.admin, userStub.user1.id, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('update user information should throw error if user not found', async () => {
userMock.get.mockResolvedValueOnce(null);
await expect(
sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('delete', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('cannot delete admin user', async () => {
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
});
it('should require the auth user be an admin', async () => {
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('should delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.DELETED,
deletedAt: expect.any(Date),
});
});
it('should force delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual(
mapUserAdmin(userStub.user1),
);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.REMOVING,
deletedAt: expect.any(Date),
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.USER_DELETION,
data: { id: userStub.user1.id, force: true },
});
});
});
describe('restore', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('should restore an user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUserAdmin(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null });
});
});
});

View file

@ -0,0 +1,154 @@
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants';
import { UserCore } from 'src/cores/user.core';
import { AuthDto } from 'src/dtos/auth.dto';
import {
UserAdminCreateDto,
UserAdminDeleteDto,
UserAdminResponseDto,
UserAdminSearchDto,
UserAdminUpdateDto,
mapUserAdmin,
} from 'src/dtos/user.dto';
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
import { UserStatus } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
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';
@Injectable()
export class UserAdminService {
private userCore: UserCore;
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.userCore = UserCore.create(cryptoRepository, userRepository);
this.logger.setContext(UserAdminService.name);
}
async search(auth: AuthDto, dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: dto.withDeleted });
return users.map((user) => mapUserAdmin(user));
}
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 tempPassword = user.shouldChangePassword ? rest.password : undefined;
if (notify) {
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } });
}
return mapUserAdmin(user);
}
async get(auth: AuthDto, id: string): Promise<UserAdminResponseDto> {
const user = await this.findOrFail(id, { withDeleted: true });
return mapUserAdmin(user);
}
async update(auth: AuthDto, id: string, dto: UserAdminUpdateDto): Promise<UserAdminResponseDto> {
const user = await this.findOrFail(id, {});
if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) {
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) {
throw new BadRequestException('Email already in use by another account');
}
}
if (dto.storageLabel) {
const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel);
if (duplicate && duplicate.id !== id) {
throw new BadRequestException('Storage label already in use by another account');
}
}
if (dto.password) {
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
}
if (dto.storageLabel === '') {
dto.storageLabel = null;
}
const updatedUser = await this.userRepository.update(id, { ...dto, updatedAt: new Date() });
return mapUserAdmin(updatedUser);
}
async delete(auth: AuthDto, id: string, dto: UserAdminDeleteDto): Promise<UserAdminResponseDto> {
const { force } = dto;
const { isAdmin } = await this.findOrFail(id, {});
if (isAdmin) {
throw new ForbiddenException('Cannot delete admin user');
}
await this.albumRepository.softDeleteAll(id);
const status = force ? UserStatus.REMOVING : UserStatus.DELETED;
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
if (force) {
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } });
}
return mapUserAdmin(user);
}
async restore(auth: AuthDto, id: string): Promise<UserAdminResponseDto> {
await this.findOrFail(id, { withDeleted: true });
await this.albumRepository.restoreAll(id);
const user = await this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE });
return mapUserAdmin(user);
}
private async findOrFail(id: string, options: UserFindOptions) {
const user = await this.userRepository.get(id, options);
if (!user) {
throw new BadRequestException('User not found');
}
return user;
}
}

View file

@ -1,11 +1,5 @@
import { import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
BadRequestException, import { UserEntity } from 'src/entities/user.entity';
ForbiddenException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { UpdateUserDto, mapUser } from 'src/dtos/user.dto';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface';
@ -63,13 +57,13 @@ describe(UserService.name, () => {
describe('getAll', () => { describe('getAll', () => {
it('should get all users', async () => { it('should get all users', async () => {
userMock.getList.mockResolvedValue([userStub.admin]); userMock.getList.mockResolvedValue([userStub.admin]);
await expect(sut.getAll(authStub.admin, false)).resolves.toEqual([ await expect(sut.search()).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({
id: authStub.admin.user.id, id: authStub.admin.user.id,
email: authStub.admin.user.email, email: authStub.admin.user.email,
}), }),
]); ]);
expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: false });
}); });
}); });
@ -82,255 +76,17 @@ describe(UserService.name, () => {
it('should throw an error if a user is not found', async () => { it('should throw an error if a user is not found', async () => {
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(NotFoundException); await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false });
}); });
}); });
describe('getMe', () => { describe('getMe', () => {
it("should get the auth user's info", async () => { it("should get the auth user's info", () => {
userMock.get.mockResolvedValue(userStub.admin); const user = authStub.admin.user;
await sut.getMe(authStub.admin); expect(sut.getMe(authStub.admin)).toMatchObject({
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, {}); id: user.id,
}); email: user.email,
it('should throw an error if a user is not found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.getMe(authStub.admin)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, {});
});
});
describe('update', () => {
it('should update user', async () => {
const update: UpdateUserDto = {
id: userStub.user1.id,
shouldChangePassword: true,
email: 'immich@test.com',
storageLabel: 'storage_label',
};
userMock.getByEmail.mockResolvedValue(null);
userMock.getByStorageLabel.mockResolvedValue(null);
userMock.update.mockResolvedValue(userStub.user1);
await sut.update({ user: { ...authStub.user1.user, isAdmin: true } }, update);
expect(userMock.getByEmail).toHaveBeenCalledWith(update.email);
expect(userMock.getByStorageLabel).toHaveBeenCalledWith(update.storageLabel);
});
it('should not set an empty string for storage label', async () => {
userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.admin, { id: userStub.user1.id, storageLabel: '' });
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: userStub.user1.id,
storageLabel: null,
updatedAt: expect.any(Date),
});
});
it('should omit a storage label set by non-admin users', async () => {
userMock.update.mockResolvedValue(userStub.user1);
await sut.update({ user: userStub.user1 }, { id: userStub.user1.id, storageLabel: 'admin' });
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: userStub.user1.id,
updatedAt: expect.any(Date),
});
});
it('user can only update its information', async () => {
userMock.get.mockResolvedValueOnce({
...userStub.user1,
id: 'not_immich_auth_user_id',
});
const result = sut.update(
{ user: userStub.user1 },
{
id: 'not_immich_auth_user_id',
password: 'I take over your account now',
},
);
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
});
it('should let a user change their email', async () => {
const dto = { id: userStub.user1.id, email: 'updated@test.com' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await sut.update({ user: userStub.user1 }, dto);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: 'user-id',
email: 'updated@test.com',
updatedAt: expect.any(Date),
});
});
it('should not let a user change their email to one already in use', async () => {
const dto = { id: userStub.user1.id, email: 'updated@test.com' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.getByEmail.mockResolvedValue(userStub.admin);
await expect(sut.update({ user: userStub.user1 }, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('should not let the admin change the storage label to one already in use', async () => {
const dto = { id: userStub.user1.id, storageLabel: 'admin' };
userMock.get.mockResolvedValue(userStub.user1);
userMock.getByStorageLabel.mockResolvedValue(userStub.admin);
await expect(sut.update(authStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('admin can update any user information', async () => {
const update: UpdateUserDto = {
id: userStub.user1.id,
shouldChangePassword: true,
};
userMock.update.mockResolvedValueOnce(userStub.user1);
await sut.update(authStub.admin, update);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
id: 'user-id',
shouldChangePassword: true,
updatedAt: expect.any(Date),
});
});
it('update user information should throw error if user not found', async () => {
userMock.get.mockResolvedValueOnce(null);
const result = sut.update(authStub.admin, {
id: userStub.user1.id,
shouldChangePassword: true,
});
await expect(result).rejects.toBeInstanceOf(BadRequestException);
});
it('should let the admin update himself', async () => {
const dto = { id: userStub.admin.id, shouldChangePassword: true, isAdmin: true };
userMock.update.mockResolvedValueOnce(userStub.admin);
await sut.update(authStub.admin, dto);
expect(userMock.update).toHaveBeenCalledWith(userStub.admin.id, { ...dto, updatedAt: expect.any(Date) });
});
it('should not let the another user become an admin', async () => {
const dto = { id: userStub.user1.id, shouldChangePassword: true, isAdmin: true };
userMock.get.mockResolvedValueOnce(userStub.user1);
await expect(sut.update(authStub.admin, dto)).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('restore', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled();
});
it('should restore an user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null });
});
});
describe('delete', () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('cannot delete admin user', async () => {
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
});
it('should require the auth user be an admin', async () => {
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('should delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.DELETED,
deletedAt: expect.any(Date),
});
});
it('should force delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual(
mapUser(userStub.user1),
);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.REMOVING,
deletedAt: expect.any(Date),
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.USER_DELETION,
data: { id: userStub.user1.id, force: true },
});
});
});
describe('create', () => {
it('should not create a user if there is no local admin account', async () => {
userMock.getAdmin.mockResolvedValueOnce(null);
await expect(
sut.create({
email: 'john_smith@email.com',
name: 'John Smith',
password: 'password',
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create user', async () => {
userMock.getAdmin.mockResolvedValue(userStub.admin);
userMock.create.mockResolvedValue(userStub.user1);
await expect(
sut.create({
email: userStub.user1.email,
name: userStub.user1.name,
password: 'password',
storageLabel: 'label',
}),
).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.getAdmin).toBeCalled();
expect(userMock.create).toBeCalledWith({
email: userStub.user1.email,
name: userStub.user1.name,
storageLabel: 'label',
password: expect.anything(),
}); });
}); });
}); });

View file

@ -1,13 +1,13 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
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 { UserCore } from 'src/cores/user.core';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto'; import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto, mapUser } 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';
import { UserEntity, UserStatus } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@ -21,73 +21,30 @@ import { getPreferences, getPreferencesPartial } from 'src/utils/preferences';
@Injectable() @Injectable()
export class UserService { export class UserService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private userCore: UserCore;
constructor( constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.userCore = UserCore.create(cryptoRepository, userRepository);
this.logger.setContext(UserService.name); this.logger.setContext(UserService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
async listUsers(): Promise<UserResponseDto[]> { async search(): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true }); const users = await this.userRepository.getList({ withDeleted: false });
return users.map((user) => mapUser(user)); return users.map((user) => mapUser(user));
} }
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> { getMe(auth: AuthDto): UserAdminResponseDto {
const users = await this.userRepository.getList({ withDeleted: !isAll }); return mapUserAdmin(auth.user);
return users.map((user) => mapUser(user));
} }
async get(userId: string): Promise<UserResponseDto> { async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
const user = await this.userRepository.get(userId, { withDeleted: false });
if (!user) {
throw new NotFoundException('User not found');
}
return mapUser(user);
}
getMe(auth: AuthDto): Promise<UserResponseDto> {
return this.findOrFail(auth.user.id, {}).then(mapUser);
}
async create(dto: CreateUserDto): Promise<UserResponseDto> {
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 tempPassword = user.shouldChangePassword ? rest.password : undefined;
if (notify) {
await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id: user.id, tempPassword } });
}
return mapUser(user);
}
async update(auth: AuthDto, dto: UpdateUserDto): Promise<UserResponseDto> {
const user = await this.findOrFail(dto.id, {});
if (dto.quotaSizeInBytes && user.quotaSizeInBytes !== dto.quotaSizeInBytes) {
await this.userRepository.syncUsage(dto.id);
}
// TODO replace with entire preferences object // TODO replace with entire preferences object
if (dto.memoriesEnabled !== undefined || dto.avatarColor) { if (dto.memoriesEnabled !== undefined || dto.avatarColor) {
const newPreferences = getPreferences(user); const newPreferences = getPreferences(user);
@ -101,42 +58,40 @@ export class UserService {
delete dto.avatarColor; delete dto.avatarColor;
} }
await this.userRepository.upsertMetadata(dto.id, { await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES, key: UserMetadataKey.PREFERENCES,
value: getPreferencesPartial(user, newPreferences), value: getPreferencesPartial(user, newPreferences),
}); });
} }
const updatedUser = await this.userCore.updateUser(auth.user, dto.id, dto); if (dto.email) {
const duplicate = await this.userRepository.getByEmail(dto.email);
if (duplicate && duplicate.id !== user.id) {
throw new BadRequestException('Email already in use by another account');
}
}
return mapUser(updatedUser); const update: Partial<UserEntity> = {
email: dto.email,
name: dto.name,
};
if (dto.password) {
const hashedPassword = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
update.password = hashedPassword;
update.shouldChangePassword = false;
}
const updatedUser = await this.userRepository.update(user.id, update);
return mapUserAdmin(updatedUser);
} }
async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise<UserResponseDto> { async get(id: string): Promise<UserResponseDto> {
const { force } = dto; const user = await this.findOrFail(id, { withDeleted: false });
const { isAdmin } = await this.findOrFail(id, {});
if (isAdmin) {
throw new ForbiddenException('Cannot delete admin user');
}
await this.albumRepository.softDeleteAll(id);
const status = force ? UserStatus.REMOVING : UserStatus.DELETED;
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
if (force) {
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } });
}
return mapUser(user); return mapUser(user);
} }
async restore(auth: AuthDto, id: string): Promise<UserResponseDto> {
await this.findOrFail(id, { withDeleted: true });
await this.albumRepository.restoreAll(id);
return this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }).then(mapUser);
}
async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise<CreateProfileImageResponseDto> { async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false }); const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path }); const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path });

View file

@ -154,7 +154,7 @@ export function validateCronExpression(expression: string) {
type IValue = { value: string }; type IValue = { value: string };
export const toEmail = ({ value }: IValue) => value?.toLowerCase(); export const toEmail = ({ value }: IValue) => (value ? value.toLowerCase() : value);
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', '')); export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', ''));

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { deleteUser, type UserResponseDto } from '@immich/sdk'; import { deleteUserAdmin, type UserResponseDto } from '@immich/sdk';
import { serverConfig } from '$lib/stores/server-config.store'; import { serverConfig } from '$lib/stores/server-config.store';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Checkbox from '$lib/components/elements/checkbox.svelte'; import Checkbox from '$lib/components/elements/checkbox.svelte';
@ -20,9 +20,9 @@
const handleDeleteUser = async () => { const handleDeleteUser = async () => {
try { try {
const { deletedAt } = await deleteUser({ const { deletedAt } = await deleteUserAdmin({
id: user.id, id: user.id,
deleteUserDto: { force: forceDelete }, userAdminDeleteDto: { force: forceDelete },
}); });
if (deletedAt == undefined) { if (deletedAt == undefined) {

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { restoreUser, type UserResponseDto } from '@immich/sdk'; import { restoreUserAdmin, type UserResponseDto } from '@immich/sdk';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
export let user: UserResponseDto; export let user: UserResponseDto;
@ -14,7 +14,7 @@
const handleRestoreUser = async () => { const handleRestoreUser = async () => {
try { try {
const { deletedAt } = await restoreUser({ id: user.id }); const { deletedAt } = await restoreUserAdmin({ id: user.id });
if (deletedAt == undefined) { if (deletedAt == undefined) {
dispatch('success'); dispatch('success');
} else { } else {

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import {
getMyUserInfo, getMyUser,
removeUserFromAlbum, removeUserFromAlbum,
type AlbumResponseDto, type AlbumResponseDto,
type UserResponseDto, type UserResponseDto,
@ -36,7 +36,7 @@
onMount(async () => { onMount(async () => {
try { try {
currentUser = await getMyUserInfo(); currentUser = await getMyUser();
} catch (error) { } catch (error) {
handleError(error, 'Unable to refresh user'); handleError(error, 'Unable to refresh user');
} }

View file

@ -7,7 +7,7 @@
import { import {
AlbumUserRole, AlbumUserRole,
getAllSharedLinks, getAllSharedLinks,
getAllUsers, searchUsers,
type AlbumResponseDto, type AlbumResponseDto,
type AlbumUserAddDto, type AlbumUserAddDto,
type SharedLinkResponseDto, type SharedLinkResponseDto,
@ -36,10 +36,10 @@
let sharedLinks: SharedLinkResponseDto[] = []; let sharedLinks: SharedLinkResponseDto[] = [];
onMount(async () => { onMount(async () => {
await getSharedLinks(); await getSharedLinks();
const data = await getAllUsers({ isAll: false }); const data = await searchUsers();
// remove invalid users // remove album owner
users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId)); users = data.filter((user) => user.id !== album.ownerId);
// Remove the existed shared users from the album // Remove the existed shared users from the album
for (const sharedUser of album.albumUsers) { for (const sharedUser of album.albumUsers) {

View file

@ -2,9 +2,8 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import PasswordField from '../shared-components/password-field.svelte'; import PasswordField from '../shared-components/password-field.svelte';
import { updateUser, type UserResponseDto } from '@immich/sdk'; import { updateMyUser } from '@immich/sdk';
export let user: UserResponseDto;
let errorMessage: string; let errorMessage: string;
let success: string; let success: string;
@ -31,13 +30,7 @@
if (valid) { if (valid) {
errorMessage = ''; errorMessage = '';
await updateUser({ await updateMyUser({ userUpdateMeDto: { password: String(password) } });
updateUserDto: {
id: user.id,
password: String(password),
shouldChangePassword: false,
},
});
dispatch('success'); dispatch('success');
} }

View file

@ -2,7 +2,7 @@
import { serverInfo } from '$lib/stores/server-info.store'; import { serverInfo } from '$lib/stores/server-info.store';
import { convertToBytes } from '$lib/utils/byte-converter'; import { convertToBytes } from '$lib/utils/byte-converter';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { createUser } from '@immich/sdk'; import { createUserAdmin } from '@immich/sdk';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import PasswordField from '../shared-components/password-field.svelte'; import PasswordField from '../shared-components/password-field.svelte';
@ -49,8 +49,8 @@
error = ''; error = '';
try { try {
await createUser({ await createUserAdmin({
createUserDto: { userAdminCreateDto: {
email, email,
password, password,
shouldChangePassword, shouldChangePassword,

View file

@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { serverInfo } from '$lib/stores/server-info.store'; import { serverInfo } from '$lib/stores/server-info.store';
import { convertFromBytes, convertToBytes } from '$lib/utils/byte-converter'; import { convertFromBytes, convertToBytes } from '$lib/utils/byte-converter';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { updateUser, type UserResponseDto } from '@immich/sdk'; import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { mdiAccountEditOutline } from '@mdi/js';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { mdiAccountEditOutline } from '@mdi/js';
export let user: UserResponseDto; export let user: UserAdminResponseDto;
export let canResetPassword = true; export let canResetPassword = true;
export let newPassword: string; export let newPassword: string;
export let onClose: () => void; export let onClose: () => void;
@ -36,9 +36,9 @@
const editUser = async () => { const editUser = async () => {
try { try {
const { id, email, name, storageLabel } = user; const { id, email, name, storageLabel } = user;
await updateUser({ await updateUserAdmin({
updateUserDto: { id,
id, userAdminUpdateDto: {
email, email,
name, name,
storageLabel: storageLabel || '', storageLabel: storageLabel || '',
@ -56,9 +56,9 @@
try { try {
newPassword = generatePassword(); newPassword = generatePassword();
await updateUser({ await updateUserAdmin({
updateUserDto: { id: user.id,
id: user.id, userAdminUpdateDto: {
password: newPassword, password: newPassword,
shouldChangePassword: true, shouldChangePassword: true,
}, },

View file

@ -4,7 +4,7 @@
import FullScreenModal from '../shared-components/full-screen-modal.svelte'; import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { mdiFolderSync } from '@mdi/js'; import { mdiFolderSync } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getAllUsers } from '@immich/sdk'; import { searchUsersAdmin } from '@immich/sdk';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
@ -13,7 +13,7 @@
let userOptions: { value: string; text: string }[] = []; let userOptions: { value: string; text: string }[] = [];
onMount(async () => { onMount(async () => {
const users = await getAllUsers({ isAll: true }); const users = await searchUsersAdmin({});
userOptions = users.map((user) => ({ value: user.id, text: user.name })); userOptions = users.map((user) => ({ value: user.id, text: user.name }));
}); });

View file

@ -4,7 +4,7 @@
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { deleteProfileImage, updateUser, type UserAvatarColor } from '@immich/sdk'; import { deleteProfileImage, updateMyUser, 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';
@ -27,9 +27,8 @@
await deleteProfileImage(); await deleteProfileImage();
} }
$user = await updateUser({ $user = await updateMyUser({
updateUserDto: { userUpdateMeDto: {
id: $user.id,
email: $user.email, email: $user.email,
name: $user.name, name: $user.name,
avatarColor: color, avatarColor: color,

View file

@ -3,23 +3,18 @@
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { updateUser, type UserResponseDto } from '@immich/sdk'; import { updateMyUser, type UserAdminResponseDto } 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 Button from '../elements/buttons/button.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import Button from '../elements/buttons/button.svelte';
export let user: UserResponseDto; export let user: UserAdminResponseDto;
const handleSave = async () => { const handleSave = async () => {
try { try {
const data = await updateUser({ const data = await updateMyUser({ userUpdateMeDto: { memoriesEnabled: user.memoriesEnabled } });
updateUserDto: {
id: user.id,
memoriesEnabled: user.memoriesEnabled,
},
});
Object.assign(user, data); Object.assign(user, data);

View file

@ -2,7 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { oauth } from '$lib/utils'; import { oauth } from '$lib/utils';
import { type UserResponseDto } from '@immich/sdk'; import { type UserAdminResponseDto } from '@immich/sdk';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error'; import { handleError } from '../../utils/handle-error';
@ -10,7 +10,7 @@
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification'; import { notificationController, NotificationType } from '../shared-components/notification/notification';
export let user: UserResponseDto; export let user: UserAdminResponseDto;
let loading = true; let loading = true;

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getAllUsers, getPartners, type UserResponseDto } from '@immich/sdk'; import { searchUsers, getPartners, type UserResponseDto } from '@immich/sdk';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import Button from '../elements/buttons/button.svelte'; import Button from '../elements/buttons/button.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte';
@ -14,11 +14,10 @@
const dispatch = createEventDispatcher<{ 'add-users': UserResponseDto[] }>(); const dispatch = createEventDispatcher<{ 'add-users': UserResponseDto[] }>();
onMount(async () => { onMount(async () => {
// TODO: update endpoint to have a query param for deleted users let users = await searchUsers();
let users = await getAllUsers({ isAll: false });
// remove invalid users // remove current user
users = users.filter((_user) => !(_user.deletedAt || _user.id === user.id)); users = users.filter((_user) => _user.id !== user.id);
// exclude partners from the list of users available for selection // exclude partners from the list of users available for selection
const partners = await getPartners({ direction: 'shared-by' }); const partners = await getPartners({ direction: 'shared-by' });

View file

@ -3,23 +3,22 @@
notificationController, notificationController,
NotificationType, NotificationType,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import Button from '../elements/buttons/button.svelte';
import { user } from '$lib/stores/user.store';
import { cloneDeep } from 'lodash-es';
import { updateUser } from '@immich/sdk';
import SettingInputField, { import SettingInputField, {
SettingInputFieldType, SettingInputFieldType,
} from '$lib/components/shared-components/settings/setting-input-field.svelte'; } from '$lib/components/shared-components/settings/setting-input-field.svelte';
import { user } from '$lib/stores/user.store';
import { updateMyUser } from '@immich/sdk';
import { cloneDeep } from 'lodash-es';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
import Button from '../elements/buttons/button.svelte';
let editedUser = cloneDeep($user); let editedUser = cloneDeep($user);
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
try { try {
const data = await updateUser({ const data = await updateMyUser({
updateUserDto: { userUpdateMeDto: {
id: editedUser.id,
email: editedUser.email, email: editedUser.email,
name: editedUser.name, name: editedUser.name,
}, },

View file

@ -1,12 +1,12 @@
import type { UserResponseDto } from '@immich/sdk'; import type { UserAdminResponseDto } from '@immich/sdk';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
export const user = writable<UserResponseDto>(); export const user = writable<UserAdminResponseDto>();
/** /**
* Reset the store to its initial undefined value. Make sure to * Reset the store to its initial undefined value. Make sure to
* only do this _after_ redirecting to an unauthenticated page. * only do this _after_ redirecting to an unauthenticated page.
*/ */
export const resetSavedUser = () => { export const resetSavedUser = () => {
user.set(undefined as unknown as UserResponseDto); user.set(undefined as unknown as UserAdminResponseDto);
}; };

View file

@ -11,7 +11,6 @@ import {
startOAuth, startOAuth,
unlinkOAuthAccount, unlinkOAuthAccount,
type SharedLinkResponseDto, type SharedLinkResponseDto,
type UserResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiImageRefreshOutline } from '@mdi/js'; import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiImageRefreshOutline } from '@mdi/js';
@ -264,7 +263,7 @@ export const oauth = {
login: (location: Location) => { login: (location: Location) => {
return finishOAuth({ oAuthCallbackDto: { url: location.href } }); return finishOAuth({ oAuthCallbackDto: { url: location.href } });
}, },
link: (location: Location): Promise<UserResponseDto> => { link: (location: Location) => {
return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } }); return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } });
}, },
unlink: () => { unlink: () => {

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 { user } from '$lib/stores/user.store';
import { getMyUserInfo, getStorage } from '@immich/sdk'; import { 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';
@ -15,7 +15,7 @@ export const loadUser = async () => {
try { try {
let loaded = get(user); let loaded = get(user);
if (!loaded && hasAuthCookie()) { if (!loaded && hasAuthCookie()) {
loaded = await getMyUserInfo(); loaded = await getMyUser();
user.set(loaded); user.set(loaded);
} }
return loaded; return loaded;

View file

@ -1,12 +1,12 @@
import { authenticate } from '$lib/utils/auth'; import { authenticate } from '$lib/utils/auth';
import { getAssetInfoFromParam } from '$lib/utils/navigation'; import { getAssetInfoFromParam } from '$lib/utils/navigation';
import { getUserById } from '@immich/sdk'; import { getUser } from '@immich/sdk';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async ({ params }) => { export const load = (async ({ params }) => {
await authenticate(); await authenticate();
const partner = await getUserById({ id: params.userId }); const partner = await getUser({ id: params.userId });
const asset = await getAssetInfoFromParam(params); const asset = await getAssetInfoFromParam(params);
return { return {
asset, asset,

View file

@ -23,7 +23,7 @@
deleteLibrary, deleteLibrary,
getAllLibraries, getAllLibraries,
getLibraryStatistics, getLibraryStatistics,
getUserById, getUserAdmin,
removeOfflineFiles, removeOfflineFiles,
scanLibrary, scanLibrary,
updateLibrary, updateLibrary,
@ -99,7 +99,7 @@
const refreshStats = async (listIndex: number) => { const refreshStats = async (listIndex: number) => {
stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id }); stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
owner[listIndex] = await getUserById({ id: libraries[listIndex].ownerId }); owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId });
photos[listIndex] = stats[listIndex].photos; photos[listIndex] = stats[listIndex].photos;
videos[listIndex] = stats[listIndex].videos; videos[listIndex] = stats[listIndex].videos;
totalCount[listIndex] = stats[listIndex].total; totalCount[listIndex] = stats[listIndex].total;

View file

@ -1,11 +1,11 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth'; import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getAllUsers } from '@immich/sdk'; import { searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async () => { export const load = (async () => {
await authenticate({ admin: true }); await authenticate({ admin: true });
await requestServerInfo(); await requestServerInfo();
const allUsers = await getAllUsers({ isAll: false }); const allUsers = await searchUsersAdmin({ withDeleted: false });
return { return {
allUsers, allUsers,

View file

@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialogue.svelte'; import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialogue.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import RestoreDialogue from '$lib/components/admin-page/restore-dialogue.svelte'; import RestoreDialogue from '$lib/components/admin-page/restore-dialogue.svelte';
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 LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte'; import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte'; import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { import {
NotificationType, NotificationType,
notificationController, notificationController,
@ -17,28 +18,27 @@
import { serverConfig } from '$lib/stores/server-config.store'; import { serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { asByteUnitString } from '$lib/utils/byte-units';
import { copyToClipboard } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import { UserStatus, getAllUsers, type UserResponseDto } from '@immich/sdk'; import { asByteUnitString } from '$lib/utils/byte-units';
import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { mdiClose, mdiContentCopy, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; import { mdiClose, mdiContentCopy, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
export let data: PageData; export let data: PageData;
let allUsers: UserResponseDto[] = []; let allUsers: UserAdminResponseDto[] = [];
let shouldShowEditUserForm = false; let shouldShowEditUserForm = false;
let shouldShowCreateUserForm = false; let shouldShowCreateUserForm = false;
let shouldShowPasswordResetSuccess = false; let shouldShowPasswordResetSuccess = false;
let shouldShowDeleteConfirmDialog = false; let shouldShowDeleteConfirmDialog = false;
let shouldShowRestoreDialog = false; let shouldShowRestoreDialog = false;
let selectedUser: UserResponseDto; let selectedUser: UserAdminResponseDto;
let newPassword: string; let newPassword: string;
const refresh = async () => { const refresh = async () => {
allUsers = await getAllUsers({ isAll: false }); allUsers = await searchUsersAdmin({ withDeleted: true });
}; };
const onDeleteSuccess = (userId: string) => { const onDeleteSuccess = (userId: string) => {
@ -75,7 +75,7 @@
shouldShowCreateUserForm = false; shouldShowCreateUserForm = false;
}; };
const editUserHandler = (user: UserResponseDto) => { const editUserHandler = (user: UserAdminResponseDto) => {
selectedUser = user; selectedUser = user;
shouldShowEditUserForm = true; shouldShowEditUserForm = true;
}; };
@ -91,7 +91,7 @@
shouldShowPasswordResetSuccess = true; shouldShowPasswordResetSuccess = true;
}; };
const deleteUserHandler = (user: UserResponseDto) => { const deleteUserHandler = (user: UserAdminResponseDto) => {
selectedUser = user; selectedUser = user;
shouldShowDeleteConfirmDialog = true; shouldShowDeleteConfirmDialog = true;
}; };
@ -101,7 +101,7 @@
shouldShowDeleteConfirmDialog = false; shouldShowDeleteConfirmDialog = false;
}; };
const restoreUserHandler = (user: UserResponseDto) => { const restoreUserHandler = (user: UserAdminResponseDto) => {
selectedUser = user; selectedUser = user;
shouldShowRestoreDialog = true; shouldShowRestoreDialog = true;
}; };

View file

@ -1,11 +1,11 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth'; import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getAllUsers } from '@immich/sdk'; import { searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async () => { export const load = (async () => {
await authenticate({ admin: true }); await authenticate({ admin: true });
await requestServerInfo(); await requestServerInfo();
const allUsers = await getAllUsers({ isAll: false }); const allUsers = await searchUsersAdmin({ withDeleted: true });
return { return {
allUsers, allUsers,

View file

@ -25,5 +25,5 @@
enter the new password below. enter the new password below.
</p> </p>
<ChangePasswordForm user={$user} on:success={onSuccess} /> <ChangePasswordForm on:success={onSuccess} />
</FullscreenContainer> </FullscreenContainer>

View file

@ -1,22 +1,11 @@
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { UserAvatarColor, UserStatus, type UserResponseDto } from '@immich/sdk'; import { UserAvatarColor, type UserResponseDto } from '@immich/sdk';
import { Sync } from 'factory.ts'; import { Sync } from 'factory.ts';
export const userFactory = Sync.makeFactory<UserResponseDto>({ export const userFactory = Sync.makeFactory<UserResponseDto>({
id: Sync.each(() => faker.string.uuid()), id: Sync.each(() => faker.string.uuid()),
email: Sync.each(() => faker.internet.email()), email: Sync.each(() => faker.internet.email()),
name: Sync.each(() => faker.person.fullName()), name: Sync.each(() => faker.person.fullName()),
storageLabel: Sync.each(() => faker.string.alphanumeric()),
profileImagePath: '', profileImagePath: '',
shouldChangePassword: Sync.each(() => faker.datatype.boolean()),
isAdmin: true,
createdAt: Sync.each(() => faker.date.past().toISOString()),
deletedAt: null,
updatedAt: Sync.each(() => faker.date.past().toISOString()),
memoriesEnabled: true,
oauthId: '',
avatarColor: UserAvatarColor.Primary, avatarColor: UserAvatarColor.Primary,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: UserStatus.Active,
}); });