1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 22:51:59 +00:00

merge main

This commit is contained in:
martabal 2024-05-28 00:04:45 +02:00
commit fec3a04123
No known key found for this signature in database
GPG key ID: C00196E3148A52BD
140 changed files with 2180 additions and 1647 deletions

View file

@ -1,4 +1,4 @@
import { getMyUserInfo } from '@immich/sdk';
import { getMyUser } from '@immich/sdk';
import { existsSync } from 'node:fs';
import { mkdir, unlink } from 'node:fs/promises';
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);
const [error, userInfo] = await withError(getMyUserInfo());
const [error, user] = await withError(getMyUser());
if (error) {
logError(error, 'Failed to load user info');
process.exit(1);
}
console.log(`Logged in as ${userInfo.email}`);
console.log(`Logged in as ${user.email}`);
if (!existsSync(configDir)) {
// 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';
export const serverInfo = async (options: BaseOptions) => {
@ -8,7 +8,7 @@ export const serverInfo = async (options: BaseOptions) => {
getServerVersion(),
getSupportedMediaTypes(),
getAssetStatistics({}),
getMyUserInfo(),
getMyUser(),
]);
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 { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
@ -48,7 +48,7 @@ export const connect = async (url: string, key: string) => {
init({ baseUrl: url, apiKey: key });
const [error] = await withError(getMyUserInfo());
const [error] = await withError(getMyUser());
if (isHttpError(error)) {
logError(error, 'Failed to connect to server');
process.exit(1);

View file

@ -9,6 +9,9 @@ services:
container_name: immich_server
command: ['/usr/src/app/bin/immich-dev']
image: immich-server-dev:latest
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
build:
context: ../
dockerfile: server/Dockerfile

View file

@ -4,6 +4,9 @@ services:
immich-server:
container_name: immich_server
image: immich-server:latest
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
build:
context: ../
dockerfile: server/Dockerfile

View file

@ -12,6 +12,9 @@ services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View file

@ -110,8 +110,44 @@ Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to
## Example Configuration
<details>
<summary>Authentik Example</summary>
### Authentik Example
Here's an example of OAuth configured for Authentik:
![OAuth Settings](./img/oauth-settings.png)
<img src={require('./img/oauth-settings.png').default} title="OAuth settings" />
</details>
<details>
<summary>Google Example</summary>
### Google Example
Configuration of Authorised redirect URIs (Google Console)
<img src={require('./img/google-example.webp').default} width='50%' title="Authorised redirect URIs" />
Configuration of OAuth in System Settings
| Setting | Value |
| ---------------------------- | ------------------------------------------------------------------------------------------------------ |
| Issuer URL | [https://accounts.google.com](https://accounts.google.com) |
| Client ID | 7\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***vuls.apps.googleusercontent.com |
| Client Secret | G\***\*\*\*\*\*\*\***\*\*\***\*\*\*\*\*\*\***OO |
| Scope | openid email profile |
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
| Button Text | Sign in with Google (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled |
| Mobile Redirect URI Override | Enabled (required) |
| Mobile Redirect URI | [https://demo.immich.app/api/oauth/mobile-redirect](https://demo.immich.app/api/oauth/mobile-redirect) |
</details>
[oidc]: https://openid.net/connect/

View file

@ -4,7 +4,7 @@ import {
AlbumUserRole,
AssetFileUploadResponseDto,
AssetOrder,
deleteUser,
deleteUserAdmin,
getAlbumInfo,
LoginResponseDto,
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', () => {

View file

@ -5,7 +5,7 @@ import {
LoginResponseDto,
SharedLinkType,
getAssetInfo,
getMyUserInfo,
getMyUser,
updateAssets,
} from '@immich/sdk';
import { exiftool } from 'exiftool-vendored';
@ -1168,7 +1168,7 @@ describe('/asset', () => {
expect(body).toEqual({ id: expect.any(String), duplicate: false });
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 }));
});

View file

@ -5,7 +5,7 @@ import {
SharedLinkResponseDto,
SharedLinkType,
createAlbum,
deleteUser,
deleteUserAdmin,
} from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
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}', () => {

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 { Socket } from 'socket.io-client';
import { createUserDto, userDto } from 'src/fixtures';
import { LoginResponseDto, SharedLinkType, deleteUserAdmin, getMyUser, login } from '@immich/sdk';
import { createUserDto } 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';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/users', () => {
let websocket: Socket;
let admin: LoginResponseDto;
let deletedUser: LoginResponseDto;
let userToDelete: LoginResponseDto;
let userToHardDelete: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
[websocket, deletedUser, nonAdmin, userToDelete, userToHardDelete] = await Promise.all([
utils.connectWebsocket(admin.accessToken),
[deletedUser, nonAdmin] = await Promise.all([
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 deleteUser({ id: deletedUser.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) });
});
afterAll(() => {
utils.disconnectWebsocket(websocket);
await deleteUserAdmin(
{ id: deletedUser.userId, userAdminDeleteDto: {} },
{ headers: asBearerAuth(admin.accessToken) },
);
});
describe('GET /users', () => {
@ -44,71 +35,14 @@ describe('/users', () => {
it('should get users', async () => {
const { status, body } = await request(app).get('/users').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(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' }),
]),
);
});
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).toHaveLength(2);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@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', () => {
@ -118,154 +52,54 @@ describe('/users', () => {
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}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: admin.userId,
email: 'admin@immich.cloud',
memoriesEnabled: true,
quotaUsageInBytes: 0,
});
});
});
describe('POST /users', () => {
describe('PUT /users/me', () => {
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(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 () => {
const dto = { [key]: null };
const { status, body } = await request(app)
.post(`/users`)
.put(`/users/me`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ...createUserDto.user1, [key]: null });
.send(dto);
expect(status).toBe(400);
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 () => {
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)
.put(`/users`)
.send({
id: admin.userId,
name: 'Name',
})
.put(`/users/me`)
.send({ name: 'Name' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
@ -274,17 +108,13 @@ describe('/users', () => {
updatedAt: expect.any(String),
name: 'Name',
});
expect(before.updatedAt).not.toEqual(body.updatedAt);
});
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)
.put(`/users`)
.send({
id: admin.userId,
memoriesEnabled: false,
})
.put(`/users/me`)
.send({ memoriesEnabled: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
@ -293,7 +123,80 @@ describe('/users', () => {
updatedAt: expect.anything(),
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,
CreateAssetDto,
CreateLibraryDto,
CreateUserDto,
MetadataSearchDto,
PersonCreateDto,
SharedLinkCreateDto,
UserAdminCreateDto,
ValidateLibraryDto,
createAlbum,
createApiKey,
@ -16,7 +16,7 @@ import {
createPartner,
createPerson,
createSharedLink,
createUser,
createUserAdmin,
deleteAssets,
getAllJobsStatus,
getAssetInfo,
@ -273,8 +273,8 @@ export const utils = {
return response;
},
userSetup: async (accessToken: string, dto: CreateUserDto) => {
await createUser({ createUserDto: dto }, { headers: asBearerAuth(accessToken) });
userSetup: async (accessToken: string, dto: UserAdminCreateDto) => {
await createUserAdmin({ userAdminCreateDto: dto }, { headers: asBearerAuth(accessToken) });
return login({
loginCredentialDto: { email: dto.email, password: dto.password },
});

View file

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

View file

@ -133,7 +133,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildTitle(Album album) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 24),
padding: const EdgeInsets.only(left: 8, right: 8),
child: userId == album.ownerId && album.isRemote
? AlbumViewerEditableTitle(
album: album,
@ -228,9 +228,30 @@ class AlbumViewerPage extends HookConsumerWidget {
}
return Scaffold(
appBar: ref.watch(multiselectProvider)
? null
: album.when(
body: Stack(
children: [
album.widgetWhen(
onData: (data) => MultiselectGrid(
renderListProvider: albumRenderlistProvider(albumId),
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
],
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
editEnabled: data.ownerId == userId,
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
top: ref.watch(multiselectProvider)
? -(kToolbarHeight + MediaQuery.of(context).padding.top)
: 0,
left: 0,
right: 0,
child: album.when(
data: (data) => AlbumViewerAppbar(
titleFocusNode: titleFocusNode,
album: data,
@ -242,19 +263,8 @@ class AlbumViewerPage extends HookConsumerWidget {
error: (error, stackTrace) => AppBar(title: const Text("Error")),
loading: () => AppBar(),
),
body: album.widgetWhen(
onData: (data) => MultiselectGrid(
renderListProvider: albumRenderlistProvider(albumId),
topWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(data),
if (data.isRemote) buildControlButton(data),
],
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
editEnabled: data.ownerId == userId,
),
],
),
);
}

View file

@ -138,11 +138,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> changePassword(String newPassword) async {
try {
await _apiService.userApi.updateUser(
UpdateUserDto(
id: state.userId,
await _apiService.userApi.updateMyUser(
UserUpdateMeDto(
password: newPassword,
shouldChangePassword: false,
),
);
@ -178,9 +176,9 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
user = offlineUser;
retResult = false;
} else {
UserResponseDto? userResponseDto;
UserAdminResponseDto? userResponseDto;
try {
userResponseDto = await _apiService.userApi.getMyUserInfo();
userResponseDto = await _apiService.userApi.getMyUser();
} on ApiException catch (error, stackTrace) {
_log.severe(
"Error getting user information from the server [API EXCEPTION]",

View file

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

View file

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

View file

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

View file

@ -36,58 +36,62 @@ class AlbumViewerEditableTitle extends HookConsumerWidget {
[],
);
return TextField(
onChanged: (value) {
if (value.isEmpty) {
} else {
ref.watch(albumViewerProvider.notifier).setEditTitleText(value);
}
},
focusNode: titleFocusNode,
style: context.textTheme.headlineMedium,
controller: titleTextEditController,
onTap: () {
FocusScope.of(context).requestFocus(titleFocusNode);
return Material(
color: Colors.transparent,
child: TextField(
onChanged: (value) {
if (value.isEmpty) {
} else {
ref.watch(albumViewerProvider.notifier).setEditTitleText(value);
}
},
focusNode: titleFocusNode,
style: context.textTheme.headlineMedium,
controller: titleTextEditController,
onTap: () {
FocusScope.of(context).requestFocus(titleFocusNode);
ref.watch(albumViewerProvider.notifier).setEditTitleText(album.name);
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
ref.watch(albumViewerProvider.notifier).setEditTitleText(album.name);
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
if (titleTextEditController.text == 'Untitled') {
titleTextEditController.clear();
}
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
suffixIcon: titleFocusNode.hasFocus
? IconButton(
onPressed: () {
titleTextEditController.clear();
},
icon: Icon(
Icons.cancel_rounded,
color: context.primaryColor,
),
splashRadius: 10,
)
: null,
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
focusColor: Colors.grey[300],
fillColor: context.isDarkTheme
? const Color.fromARGB(255, 32, 33, 35)
: Colors.grey[200],
filled: titleFocusNode.hasFocus,
hintText: 'share_add_title'.tr(),
hintStyle: TextStyle(
fontSize: 28,
color: context.isDarkTheme ? Colors.grey[300] : Colors.grey[700],
fontWeight: FontWeight.bold,
if (titleTextEditController.text == 'Untitled') {
titleTextEditController.clear();
}
},
decoration: InputDecoration(
contentPadding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
suffixIcon: titleFocusNode.hasFocus
? IconButton(
onPressed: () {
titleTextEditController.clear();
},
icon: Icon(
Icons.cancel_rounded,
color: context.primaryColor,
),
splashRadius: 10,
)
: null,
enabledBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
focusedBorder: OutlineInputBorder(
borderSide: const BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.circular(10),
),
focusColor: Colors.grey[300],
fillColor: context.isDarkTheme
? const Color.fromARGB(255, 32, 33, 35)
: Colors.grey[200],
filled: titleFocusNode.hasFocus,
hintText: 'share_add_title'.tr(),
hintStyle: TextStyle(
fontSize: 28,
color: context.isDarkTheme ? Colors.grey[300] : Colors.grey[700],
fontWeight: FontWeight.bold,
),
),
),
);

View file

@ -238,8 +238,10 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
}
bool appBarOffset() {
return ref.watch(tabProvider).index == 0 &&
ModalRoute.of(context)?.settings.name == TabControllerRoute.name;
return (ref.watch(tabProvider).index == 0 &&
ModalRoute.of(context)?.settings.name ==
TabControllerRoute.name) ||
(ModalRoute.of(context)?.settings.name == AlbumViewerRoute.name);
}
final listWidget = ScrollablePositionedList.builder(

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": {
"get": {
"operationId": "getAllAlbums",
@ -1879,7 +2147,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
@ -1910,7 +2178,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
@ -3200,7 +3468,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
@ -3246,7 +3514,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
@ -6102,17 +6370,8 @@
},
"/users": {
"get": {
"operationId": "getAllUsers",
"parameters": [
{
"name": "isAll",
"required": true,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"operationId": "searchUsers",
"parameters": [],
"responses": {
"200": {
"content": {
@ -6142,26 +6401,18 @@
"tags": [
"User"
]
},
"post": {
"operationId": "createUser",
}
},
"/users/me": {
"get": {
"operationId": "getMyUser",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserDto"
}
}
},
"required": true
},
"responses": {
"201": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
@ -6184,13 +6435,13 @@
]
},
"put": {
"operationId": "updateUser",
"operationId": "updateMyUser",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateUserDto"
"$ref": "#/components/schemas/UserUpdateMeDto"
}
}
},
@ -6201,39 +6452,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"User"
]
}
},
"/users/me": {
"get": {
"operationId": "getMyUserInfo",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
@ -6323,58 +6542,8 @@
}
},
"/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": {
"operationId": "getUserById",
"operationId": "getUser",
"parameters": [
{
"name": "id",
@ -6456,48 +6625,6 @@
"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": {
@ -6639,7 +6766,7 @@
"type": "string"
},
"user": {
"$ref": "#/components/schemas/UserDto"
"$ref": "#/components/schemas/UserResponseDto"
}
},
"required": [
@ -7844,52 +7971,6 @@
],
"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": {
"properties": {
"assetIds": {
@ -8872,15 +8953,6 @@
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"createdAt": {
"format": "date-time",
"type": "string"
},
"deletedAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"email": {
"type": "string"
},
@ -8890,62 +8962,19 @@
"inTimeline": {
"type": "boolean"
},
"isAdmin": {
"type": "boolean"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"oauthId": {
"type": "string"
},
"profileImagePath": {
"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": [
"avatarColor",
"createdAt",
"deletedAt",
"email",
"id",
"isAdmin",
"name",
"oauthId",
"profileImagePath",
"quotaSizeInBytes",
"quotaUsageInBytes",
"shouldChangePassword",
"status",
"storageLabel",
"updatedAt"
"profileImagePath"
],
"type": "object"
},
@ -10897,48 +10926,6 @@
},
"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": {
"properties": {
"photos": {
@ -10973,49 +10960,53 @@
],
"type": "object"
},
"UserAvatarColor": {
"enum": [
"primary",
"pink",
"red",
"yellow",
"blue",
"green",
"purple",
"orange",
"gray",
"amber"
],
"type": "string"
},
"UserDto": {
"UserAdminCreateDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
"id": {
"type": "string"
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"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"
}
},
"required": [
"avatarColor",
"email",
"id",
"name",
"profileImagePath"
"password"
],
"type": "object"
},
"UserResponseDto": {
"UserAdminDeleteDto": {
"properties": {
"force": {
"type": "boolean"
}
},
"type": "object"
},
"UserAdminResponseDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
@ -11094,6 +11085,81 @@
],
"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": {
"enum": [
"active",
@ -11102,6 +11168,26 @@
],
"type": "string"
},
"UserUpdateMeDto": {
"properties": {
"avatarColor": {
"$ref": "#/components/schemas/UserAvatarColor"
},
"email": {
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
"name": {
"type": "string"
},
"password": {
"type": "string"
}
},
"type": "object"
},
"ValidateAccessTokenResponseDto": {
"properties": {
"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).
```typescript
<<<<<<< HEAD
import { getAllAlbums, getAllAssets, getMyUser, init } from "@immich/sdk";
=======
import { getAllAlbums, getMyUserInfo, init } from "@immich/sdk";
>>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2
const API_KEY = "<API_KEY>"; // process.env.IMMICH_API_KEY
init({ baseUrl: "https://demo.immich.app/api", apiKey: API_KEY });
<<<<<<< HEAD
const user = await getMyUser();
const assets = await getAllAssets({ take: 1000 });
=======
const user = await getMyUserInfo();
>>>>>>> e7c8501930a988dfb6c23ce1c48b0beb076a58c2
const albums = await getAllAlbums({});
console.log({ user, albums });

View file

@ -14,7 +14,7 @@ const oazapfts = Oazapfts.runtime(defaults);
export const servers = {
server1: "/api"
};
export type UserDto = {
export type UserResponseDto = {
avatarColor: UserAvatarColor;
email: string;
id: string;
@ -27,7 +27,7 @@ export type ActivityResponseDto = {
createdAt: string;
id: string;
"type": Type;
user: UserDto;
user: UserResponseDto;
};
export type ActivityCreateDto = {
albumId: string;
@ -38,7 +38,7 @@ export type ActivityCreateDto = {
export type ActivityStatisticsResponseDto = {
comments: number;
};
export type UserResponseDto = {
export type UserAdminResponseDto = {
avatarColor: UserAvatarColor;
createdAt: string;
deletedAt: string | null;
@ -56,6 +56,29 @@ export type UserResponseDto = {
storageLabel: string | null;
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 = {
role: AlbumUserRole;
user: UserResponseDto;
@ -521,22 +544,11 @@ export type OAuthCallbackDto = {
};
export type PartnerResponseDto = {
avatarColor: UserAvatarColor;
createdAt: string;
deletedAt: string | null;
email: string;
id: string;
inTimeline?: boolean;
isAdmin: boolean;
memoriesEnabled?: boolean;
name: string;
oauthId: string;
profileImagePath: string;
quotaSizeInBytes: number | null;
quotaUsageInBytes: number | null;
shouldChangePassword: boolean;
status: UserStatus;
storageLabel: string | null;
updatedAt: string;
};
export type UpdatePartnerDto = {
inTimeline: boolean;
@ -1064,27 +1076,12 @@ export type TimeBucketResponseDto = {
count: number;
timeBucket: string;
};
export type CreateUserDto = {
email: string;
memoriesEnabled?: boolean;
name: string;
notify?: boolean;
password: string;
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string | null;
};
export type UpdateUserDto = {
export type UserUpdateMeDto = {
avatarColor?: UserAvatarColor;
email?: string;
id: string;
isAdmin?: boolean;
memoriesEnabled?: boolean;
name?: string;
password?: string;
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string;
};
export type CreateProfileImageDto = {
file: Blob;
@ -1093,9 +1090,6 @@ export type CreateProfileImageResponseDto = {
profileImagePath: string;
userId: string;
};
export type DeleteUserDto = {
force?: boolean;
};
export function getActivities({ albumId, assetId, level, $type, userId }: {
albumId: string;
assetId?: string;
@ -1150,6 +1144,77 @@ export function deleteActivity({ id }: {
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 }: {
assetId?: string;
shared?: boolean;
@ -1593,7 +1658,7 @@ export function signUpAdmin({ signUpDto }: {
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: UserResponseDto;
data: UserAdminResponseDto;
}>("/auth/admin-sign-up", oazapfts.json({
...opts,
method: "POST",
@ -1605,7 +1670,7 @@ export function changePassword({ changePasswordDto }: {
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto;
data: UserAdminResponseDto;
}>("/auth/change-password", oazapfts.json({
...opts,
method: "POST",
@ -1949,7 +2014,7 @@ export function linkOAuthAccount({ oAuthCallbackDto }: {
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: UserResponseDto;
data: UserAdminResponseDto;
}>("/oauth/link", oazapfts.json({
...opts,
method: "POST",
@ -1964,7 +2029,7 @@ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) {
export function unlinkOAuthAccount(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: UserResponseDto;
data: UserAdminResponseDto;
}>("/oauth/unlink", {
...opts,
method: "POST"
@ -2714,50 +2779,34 @@ export function restoreAssets({ bulkIdsDto }: {
body: bulkIdsDto
})));
}
export function getAllUsers({ isAll }: {
isAll: boolean;
}, opts?: Oazapfts.RequestOpts) {
export function searchUsers(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto[];
}>(`/users${QS.query(QS.explode({
isAll
}))}`, {
}>("/users", {
...opts
}));
}
export function createUser({ createUserDto }: {
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) {
export function getMyUser(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto;
}>("/users", oazapfts.json({
...opts,
method: "PUT",
body: updateUserDto
})));
}
export function getMyUserInfo(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto;
data: UserAdminResponseDto;
}>("/users/me", {
...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) {
return oazapfts.ok(oazapfts.fetchText("/users/profile-image", {
...opts,
@ -2776,20 +2825,7 @@ export function createProfileImage({ createProfileImageDto }: {
body: createProfileImageDto
})));
}
export function deleteUser({ id, deleteUserDto }: {
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 }: {
export function getUser({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
@ -2809,17 +2845,6 @@ export function getProfileImage({ id }: {
...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 {
Album = "album",
Asset = "asset"
@ -2844,15 +2869,15 @@ export enum UserAvatarColor {
Gray = "gray",
Amber = "amber"
}
export enum AlbumUserRole {
Editor = "editor",
Viewer = "viewer"
}
export enum UserStatus {
Active = "active",
Removing = "removing",
Deleted = "deleted"
}
export enum AlbumUserRole {
Editor = "editor",
Viewer = "viewer"
}
export enum TagTypeEnum {
Object = "OBJECT",
Face = "FACE",

View file

@ -11,7 +11,7 @@
<p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
</p>
<h3 align="center">Immich - Solution de sauvegarde performante et auto-hébergée des photos et des vidéos</h3>
<h3 align="center">Immich - Solution de sauvegarde performante et auto-hébergée de photos et de vidéos</h3>
<br/>
<a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Main Screenshot">
@ -36,16 +36,16 @@
## Clause de non-responsabilité
- ⚠️ Le projet est en **très fort** développement.
- ⚠️ Attendez-vous à rencontrer des bugs et des changements importants.
- ⚠️ **N'utilisez pas cette application comme seule façon de sauvegarder vos photos et vos vidéos.**
- ⚠️ Attendez-vous à rencontrer des bogues et des changements importants.
- ⚠️ **N'utilisez pas cette application comme seul support de sauvegarde de vos photos et vos vidéos.**
- ⚠️ Ayez toujours un plan de sauvegarde en [3-2-1](https://www.seagate.com/fr/fr/blog/what-is-a-3-2-1-backup-strategy/) pour vos précieuses photos et vidéos !
## Sommaire
- [Documentation officielle](https://immich.app/docs)
- [Feuille de route](https://github.com/orgs/immich-app/projects/1)
- [Démo](#demo)
- [Fonctionnalités](#features)
- [Démo](#démo)
- [Fonctionnalités](#fonctionnalités)
- [Introduction](https://immich.app/docs/overview/introduction)
- [Installation](https://immich.app/docs/install/requirements)
- [Contribution](https://immich.app/docs/overview/support-the-project)
@ -56,26 +56,31 @@ Vous pouvez trouver la documentation principale ainsi que les guides d'installat
## Démo
Vous pouvez accéder à la démo Web sur https://demo.immich.app
Vous pouvez accéder à la démo en ligne sur https://demo.immich.app
Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app/api` dans le champ 'URL du point d'accès au serveur'
Pour l'application mobile, vous pouvez utiliser `https://demo.immich.app/api` dans le champ `URL du point d'accès au serveur`
```bash title="Demo Credential"
```bash title="Identifiants pour la démo"
Les identifiants
email: demo@immich.app
mot de passe: demo
```
```
Caractéristiques: Plan gratuit Oracle VM - Amsterdam - 2.4Ghz quatre-cœurs ARM64 CPU, 24GB RAM
Caractéristiques : Plan gratuit Oracle VM - Amsterdam - 2.4Ghz quatre-cœurs ARM64 CPU, 24GB RAM
```
# Fonctionnalités
## Activités
![Activités](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Image des statistiques Repobeats")
## Fonctionnalités
| Fonctionnalités | Mobile | Web |
| ---------------------------------------------------------------- | ------ | --- |
| Téléverser et voir les vidéos et photos | Oui | Oui |
| Sauvegarde automatique quand l'application est ouverte | Oui | N/A |
| Prévention contre la duplication des photos et des vidéos | Oui | Oui |
| Sélection des albums à sauvegarder | Oui | N/A |
| Télécharger les photos et les vidéos sur l'appareil | Oui | Oui |
| Support multi-utilisateur | Oui | Oui |
@ -89,13 +94,32 @@ Caractéristiques: Plan gratuit Oracle VM - Amsterdam - 2.4Ghz quatre-cœurs ARM
| Défilement virtuel | Oui | Oui |
| Support de l'OAuth | Oui | Oui |
| Clés d'API | N/A | Oui |
| Sauvegarde et lecture des LivePhotos | iOS | Oui |
| Sauvegarde et lecture des LivePhoto/MotionPhoto | Oui | Oui |
| Support de l'affichage des images à 360° | Non | Oui |
| Structure de stockage définissable | Oui | Oui |
| Partage public | Non | Oui |
| Archives et favoris | Oui | Oui |
| Carte globale | Non | Oui |
| Carte globale | Oui | Oui |
| Partage entre utilisateurs | Oui | Oui |
| Reconnaissance et regroupement facial | Oui | Oui |
| Souvenirs (il y a x années) | Oui | Oui |
| Support hors-ligne | Oui | Non |
| Gallerie en lecture seule | Oui | Oui |
| Empilage de photos | Oui | Oui |
## Contributeurs
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
## Historique des favoris
<a href="https://star-history.com/#immich-app/immich&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
</picture>
</a>

View file

@ -1,9 +1,9 @@
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';
const prompt = (inquirer: InquirerService) => {
return function ask(admin: UserResponseDto) {
return function ask(admin: UserAdminResponseDto) {
const { id, oauthId, email, name } = admin;
console.log(`Found Admin:
- ID=${id}

View file

@ -256,8 +256,8 @@ export const defaults = Object.freeze<SystemConfig>({
modelName: 'ViT-B-32__openai',
},
duplicateDetection: {
enabled: false,
maxDistance: 0.03,
enabled: true,
maxDistance: 0.0155,
},
facialRecognition: {
enabled: true,

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import {
OAuthCallbackDto,
OAuthConfigDto,
} 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 { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@ -53,13 +53,13 @@ export class OAuthController {
@Post('link')
@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);
}
@Post('unlink')
@Authenticated()
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserResponseDto> {
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
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,
Post,
Put,
Query,
Res,
UploadedFile,
UseInterceptors,
@ -19,7 +18,7 @@ import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AuthDto } from 'src/dtos/auth.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 { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor';
@ -37,58 +36,28 @@ export class UserController {
@Get()
@Authenticated()
getAllUsers(@Auth() auth: AuthDto, @Query('isAll') isAll: boolean): Promise<UserResponseDto[]> {
return this.service.getAll(auth, isAll);
}
@Post()
@Authenticated({ admin: true })
createUser(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> {
return this.service.create(createUserDto);
searchUsers(): Promise<UserResponseDto[]> {
return this.service.search();
}
@Get('me')
@Authenticated()
getMyUserInfo(@Auth() auth: AuthDto): Promise<UserResponseDto> {
getMyUser(@Auth() auth: AuthDto): UserAdminResponseDto {
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')
@Authenticated()
getUserById(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
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)
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@ -101,6 +70,13 @@ export class UserController {
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')
@FileResponse()
@Authenticated()

View file

@ -1,7 +1,6 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import sanitize from 'sanitize-filename';
import { SALT_ROUNDS } from 'src/constants';
import { UserResponseDto } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
@ -26,46 +25,6 @@ export class UserCore {
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> {
const user = await this.userRepository.getByEmail(dto.email);
if (user) {

View file

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
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 { Optional, ValidateUUID } from 'src/validation';
@ -20,7 +20,7 @@ export class ActivityResponseDto {
id!: string;
createdAt!: Date;
type!: ReactionType;
user!: UserDto;
user!: UserResponseDto;
assetId!: string | null;
comment?: string | null;
}
@ -73,6 +73,6 @@ export function mapActivity(activity: ActivityEntity): ActivityResponseDto {
createdAt: activity.createdAt,
comment: activity.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 { 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', () => {
it('should allow emails without a tld', async () => {
const someEmail = 'test@test';
const dto = plainToInstance(UpdateUserDto, {
const dto = plainToInstance(UserUpdateMeDto, {
email: someEmail,
id: '3fe388e4-2078-44d7-b36c-39d9dee3a657',
});
@ -18,22 +18,22 @@ describe('update user DTO', () => {
describe('create user DTO', () => {
it('validates the email', async () => {
const params: Partial<CreateUserDto> = {
const params: Partial<UserAdminCreateDto> = {
email: undefined,
password: 'password',
name: 'name',
};
let dto: CreateUserDto = plainToInstance(CreateUserDto, params);
let dto: UserAdminCreateDto = plainToInstance(UserAdminCreateDto, params);
let errors = await validate(dto);
expect(errors).toHaveLength(1);
params.email = 'invalid email';
dto = plainToInstance(CreateUserDto, params);
dto = plainToInstance(UserAdminCreateDto, params);
errors = await validate(dto);
expect(errors).toHaveLength(1);
params.email = 'valid@email.com';
dto = plainToInstance(CreateUserDto, params);
dto = plainToInstance(UserAdminCreateDto, params);
errors = await validate(dto);
expect(errors).toHaveLength(0);
});
@ -41,7 +41,7 @@ describe('create user DTO', () => {
it('should allow emails without a tld', async () => {
const someEmail = 'test@test';
const dto = plainToInstance(CreateUserDto, {
const dto = plainToInstance(UserAdminCreateDto, {
email: someEmail,
password: 'some password',
name: 'some name',
@ -51,18 +51,3 @@ describe('create user DTO', () => {
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 { 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 { UserEntity, UserStatus } from 'src/entities/user.entity';
import { getPreferences } from 'src/utils/preferences';
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 })
@Transform(toEmail)
email!: string;
@ -41,23 +92,7 @@ export class CreateUserDto {
notify?: boolean;
}
export class CreateUserOAuthDto {
@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 {
export class UserAdminUpdateDto {
@Optional()
@IsEmail({ require_tld: false })
@Transform(toEmail)
@ -73,18 +108,10 @@ export class UpdateUserDto {
@IsNotEmpty()
name?: string;
@Optional()
@Optional({ nullable: true })
@IsString()
@Transform(toSanitized)
storageLabel?: string;
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
id!: string;
@ValidateBoolean({ optional: true })
isAdmin?: boolean;
storageLabel?: string | null;
@ValidateBoolean({ optional: true })
shouldChangePassword?: boolean;
@ -104,17 +131,12 @@ export class UpdateUserDto {
quotaSizeInBytes?: number | null;
}
export class UserDto {
id!: string;
name!: string;
email!: string;
profileImagePath!: string;
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor!: UserAvatarColor;
export class UserAdminDeleteDto {
@ValidateBoolean({ optional: true })
force?: boolean;
}
export class UserResponseDto extends UserDto {
export class UserAdminResponseDto extends UserResponseDto {
storageLabel!: string | null;
shouldChangePassword!: boolean;
isAdmin!: boolean;
@ -131,19 +153,9 @@ export class UserResponseDto extends UserDto {
status!: string;
}
export const mapSimpleUser = (entity: UserEntity): UserDto => {
export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto {
return {
id: entity.id,
email: entity.email,
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity).avatar.color,
};
};
export function mapUser(entity: UserEntity): UserResponseDto {
return {
...mapSimpleUser(entity),
...mapUser(entity),
storageLabel: entity.storageLabel,
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,

View file

@ -53,7 +53,7 @@ export interface VideoInfo {
audioStreams: AudioStreamInfo[];
}
export interface TranscodeOptions {
export interface TranscodeCommand {
inputOptions: string[];
outputOptions: string[];
twoPass: boolean;
@ -67,7 +67,7 @@ export interface BitrateDistribution {
}
export interface VideoCodecSWConfig {
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeOptions;
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream: AudioStreamInfo): TranscodeCommand;
}
export interface VideoCodecHWConfig extends VideoCodecSWConfig {
@ -83,5 +83,5 @@ export interface IMediaRepository {
// video
probe(input: string): Promise<VideoInfo>;
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void>;
transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise<void>;
}

View file

@ -155,8 +155,9 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
export interface AssetDuplicateSearch {
assetId: string;
embedding: Embedding;
userIds: string[];
maxDistance?: number;
type: AssetType;
userIds: string[];
}
export interface FaceSearchResult {

View file

@ -22,13 +22,17 @@ FROM
"APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status",
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
"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
"api_keys" "APIKeyEntity"
LEFT JOIN "users" "APIKeyEntity__APIKeyEntity_user" ON "APIKeyEntity__APIKeyEntity_user"."id" = "APIKeyEntity"."userId"
AND (
"APIKeyEntity__APIKeyEntity_user"."deletedAt" IS NULL
)
LEFT JOIN "user_metadata" "7f5f7a38bf327bfbbf826778460704c9a50fe6f4" ON "7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" = "APIKeyEntity__APIKeyEntity_user"."id"
WHERE
(("APIKeyEntity"."key" = $1))
) "distinctAlias"

View file

@ -204,6 +204,7 @@ WITH
"asset"."ownerId" IN ($2)
AND "asset"."id" != $3
AND "asset"."isVisible" = $4
AND "asset"."type" = $5
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
@ -216,7 +217,7 @@ SELECT
FROM
"cte" "res"
WHERE
res.distance <= $5
res.distance <= $6
-- SearchRepository.searchFaces
START TRANSACTION

View file

@ -38,13 +38,17 @@ FROM
"SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status",
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt",
"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
"sessions" "SessionEntity"
LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId"
AND (
"SessionEntity__SessionEntity_user"."deletedAt" IS NULL
)
LEFT JOIN "user_metadata" "469e6aa7ff79eff78f8441f91ba15bb07d3634dd" ON "469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" = "SessionEntity__SessionEntity_user"."id"
WHERE
(("SessionEntity"."token" = $1))
) "distinctAlias"

View file

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

View file

@ -11,7 +11,7 @@ import {
IMediaRepository,
ImageDimensions,
ThumbnailOptions,
TranscodeOptions,
TranscodeCommand,
VideoInfo,
} from 'src/interfaces/media.interface';
import { Instrumentation } from 'src/utils/instrumentation';
@ -97,7 +97,7 @@ export class MediaRepository implements IMediaRepository {
};
}
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void> {
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run();
@ -150,7 +150,7 @@ export class MediaRepository implements IMediaRepository {
return { width, height };
}
private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) {
private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) {
return ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions)

View file

@ -160,6 +160,7 @@ export class SearchRepository implements ISearchRepository {
assetId,
embedding,
maxDistance,
type,
userIds,
}: AssetDuplicateSearch): Promise<AssetDuplicateResult[]> {
const cte = this.assetRepository.createQueryBuilder('asset');
@ -171,18 +172,22 @@ export class SearchRepository implements ISearchRepository {
.where('asset.ownerId IN (:...userIds )')
.andWhere('asset.id != :assetId')
.andWhere('asset.isVisible = :isVisible')
.andWhere('asset.type = :type')
.orderBy('search.embedding <=> :embedding')
.limit(64)
.setParameters({ assetId, embedding: asVector(embedding), isVisible: true, userIds });
.setParameters({ assetId, embedding: asVector(embedding), isVisible: true, type, userIds });
const builder = this.assetRepository.manager
.createQueryBuilder()
.addCommonTableExpression(cte, 'cte')
.from('cte', 'res')
.select('res.*')
.where('res.distance <= :maxDistance', { maxDistance });
.select('res.*');
return builder.getRawMany() as any as Promise<AssetDuplicateResult[]>;
if (maxDistance) {
builder.where('res.distance <= :maxDistance', { maxDistance });
}
return builder.getRawMany() as Promise<AssetDuplicateResult[]>;
}
@GenerateSql({

View file

@ -18,7 +18,14 @@ export class SessionRepository implements ISessionRepository {
@GenerateSql({ params: [DummyValue.STRING] })
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[]> {

View file

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

View file

@ -11,7 +11,7 @@ import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
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 { UserCore } from 'src/cores/user.core';
import {
@ -27,7 +27,7 @@ import {
SignUpDto,
mapLoginResponse,
} 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 { IKeyRepository } from 'src/interfaces/api-key.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 user = await this.userRepository.getByEmail(auth.user.email, true);
if (!user) {
@ -121,10 +121,14 @@ export class AuthService {
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();
if (adminUser) {
throw new BadRequestException('The server already has an admin');
@ -138,7 +142,7 @@ export class AuthService {
storageLabel: 'admin',
});
return mapUser(admin);
return mapUserAdmin(admin);
}
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
@ -237,7 +241,7 @@ export class AuthService {
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 { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
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}).`);
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> {
return mapUser(await this.userRepository.update(auth.user.id, { oauthId: '' }));
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
return mapUserAdmin(user);
}
private async getLogoutEndpoint(authType: AuthType): Promise<string> {

View file

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@ -10,7 +10,6 @@ import { IUserRepository } from 'src/interfaces/user.interface';
@Injectable()
export class CliService {
private configCore: SystemConfigCore;
private userCore: UserCore;
constructor(
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@ -18,26 +17,26 @@ export class CliService {
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.userCore = UserCore.create(cryptoRepository, userRepository);
this.logger.setContext(CliService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
async listUsers(): Promise<UserResponseDto[]> {
async listUsers(): Promise<UserAdminResponseDto[]> {
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();
if (!admin) {
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 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 };
}

View file

@ -214,7 +214,8 @@ describe(SearchService.name, () => {
expect(searchMock.searchDuplicates).toHaveBeenCalledWith({
assetId: assetStub.hasEmbedding.id,
embedding: assetStub.hasEmbedding.smartSearch!.embedding,
maxDistance: 0.03,
maxDistance: 0.0155,
type: assetStub.hasEmbedding.type,
userIds: [assetStub.hasEmbedding.ownerId],
});
expect(assetMock.updateDuplicates).toHaveBeenCalledWith({
@ -239,7 +240,8 @@ describe(SearchService.name, () => {
expect(searchMock.searchDuplicates).toHaveBeenCalledWith({
assetId: assetStub.hasEmbedding.id,
embedding: assetStub.hasEmbedding.smartSearch!.embedding,
maxDistance: 0.03,
maxDistance: 0.0155,
type: assetStub.hasEmbedding.type,
userIds: [assetStub.hasEmbedding.ownerId],
});
expect(assetMock.updateDuplicates).toHaveBeenCalledWith({

View file

@ -94,6 +94,7 @@ export class DuplicateService {
assetId: asset.id,
embedding: asset.smartSearch.embedding,
maxDistance: machineLearning.duplicateDetection.maxDistance,
type: asset.type,
userIds: [asset.ownerId],
});

View file

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

View file

@ -294,11 +294,13 @@ describe(MediaService.name, () => {
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
inputOptions: ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
'-fps_mode vfr',
'-frames:v 1',
'-update 1',
'-v verbose',
'-vf scale=-2:1440:flags=lanczos+accurate_rnd+bitexact+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p',
`-vf fps=12,thumbnail=12,select=gt(scene\\,0.1)+gt(n\\,20),scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p`,
],
twoPass: false,
},
@ -319,11 +321,13 @@ describe(MediaService.name, () => {
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
inputOptions: ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
'-fps_mode vfr',
'-frames:v 1',
'-update 1',
'-v verbose',
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p',
`-vf fps=12,thumbnail=12,select=gt(scene\\,0.1)+gt(n\\,20),zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`,
],
twoPass: false,
},
@ -346,11 +350,13 @@ describe(MediaService.name, () => {
'/original/path.ext',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
inputOptions: ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
'-fps_mode vfr',
'-frames:v 1',
'-update 1',
'-v verbose',
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p',
`-vf fps=12,thumbnail=12,select=gt(scene\\,0.1)+gt(n\\,20),zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`,
],
twoPass: false,
},

View file

@ -27,25 +27,12 @@ import {
QueueName,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from 'src/interfaces/media.interface';
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import {
AV1Config,
H264Config,
HEVCConfig,
NvencHwDecodeConfig,
NvencSwDecodeConfig,
QsvHwDecodeConfig,
QsvSwDecodeConfig,
RkmppHwDecodeConfig,
RkmppSwDecodeConfig,
ThumbnailConfig,
VAAPIConfig,
VP9Config,
} from 'src/utils/media';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
import { usePagination } from 'src/utils/pagination';
@ -53,8 +40,8 @@ import { usePagination } from 'src/utils/pagination';
export class MediaService {
private configCore: SystemConfigCore;
private storageCore: StorageCore;
private openCL: boolean | null = null;
private devices: string[] | null = null;
private maliOpenCL?: boolean;
private devices?: string[];
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@ -232,8 +219,8 @@ export class MediaService {
return;
}
const mainAudioStream = this.getMainStream(audioStreams);
const config = { ...ffmpeg, targetResolution: size.toString() };
const options = new ThumbnailConfig(config).getOptions(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
const config = ThumbnailConfig.create({ ...ffmpeg, targetResolution: size.toString() });
const options = config.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(asset.originalPath, path, options);
break;
}
@ -331,8 +318,8 @@ export class MediaService {
return JobStatus.FAILED;
}
const { ffmpeg: config } = await this.configCore.getConfig();
const target = this.getTranscodeTarget(config, mainVideoStream, mainAudioStream);
const { ffmpeg } = await this.configCore.getConfig();
const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream);
if (target === TranscodeTarget.NONE) {
if (asset.encodedVideoPath) {
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
@ -343,30 +330,28 @@ export class MediaService {
return JobStatus.SKIPPED;
}
let transcodeOptions;
let command;
try {
transcodeOptions = await this.getCodecConfig(config).then((c) =>
c.getOptions(target, mainVideoStream, mainAudioStream),
);
const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL());
command = config.getCommand(target, mainVideoStream, mainAudioStream);
} catch (error) {
this.logger.error(`An error occurred while configuring transcoding options: ${error}`);
return JobStatus.FAILED;
}
this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`);
this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`);
try {
await this.mediaRepository.transcode(input, output, transcodeOptions);
await this.mediaRepository.transcode(input, output, command);
} catch (error) {
this.logger.error(error);
if (config.accel !== TranscodeHWAccel.DISABLED) {
if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) {
this.logger.error(
`Error occurred during transcoding. Retrying with ${config.accel.toUpperCase()} acceleration disabled.`,
`Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`,
);
}
transcodeOptions = await this.getCodecConfig({ ...config, accel: TranscodeHWAccel.DISABLED }).then((c) =>
c.getOptions(target, mainVideoStream, mainAudioStream),
);
await this.mediaRepository.transcode(input, output, transcodeOptions);
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command);
}
this.logger.log(`Successfully encoded ${asset.id}`);
@ -382,10 +367,10 @@ export class MediaService {
private getTranscodeTarget(
config: SystemConfigFFmpegDto,
videoStream: VideoStreamInfo | null,
audioStream: AudioStreamInfo | null,
videoStream?: VideoStreamInfo,
audioStream?: AudioStreamInfo,
): TranscodeTarget {
if (videoStream == null && audioStream == null) {
if (!videoStream && !audioStream) {
return TranscodeTarget.NONE;
}
@ -407,8 +392,8 @@ export class MediaService {
return TranscodeTarget.NONE;
}
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: AudioStreamInfo | null): boolean {
if (stream == null) {
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean {
if (!stream) {
return false;
}
@ -430,8 +415,8 @@ export class MediaService {
}
}
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo | null): boolean {
if (stream == null) {
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: VideoStreamInfo): boolean {
if (!stream) {
return false;
}
@ -465,70 +450,6 @@ export class MediaService {
}
}
async getCodecConfig(config: SystemConfigFFmpegDto) {
if (config.accel === TranscodeHWAccel.DISABLED) {
return this.getSWCodecConfig(config);
}
return this.getHWCodecConfig(config);
}
private getSWCodecConfig(config: SystemConfigFFmpegDto) {
switch (config.targetVideoCodec) {
case VideoCodec.H264: {
return new H264Config(config);
}
case VideoCodec.HEVC: {
return new HEVCConfig(config);
}
case VideoCodec.VP9: {
return new VP9Config(config);
}
case VideoCodec.AV1: {
return new AV1Config(config);
}
default: {
throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`);
}
}
}
private async getHWCodecConfig(config: SystemConfigFFmpegDto) {
let handler: VideoCodecHWConfig;
switch (config.accel) {
case TranscodeHWAccel.NVENC: {
handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config);
break;
}
case TranscodeHWAccel.QSV: {
handler = config.accelDecode
? new QsvHwDecodeConfig(config, await this.getDevices())
: new QsvSwDecodeConfig(config, await this.getDevices());
break;
}
case TranscodeHWAccel.VAAPI: {
handler = new VAAPIConfig(config, await this.getDevices());
break;
}
case TranscodeHWAccel.RKMPP: {
handler =
config.accelDecode && (await this.hasOpenCL())
? new RkmppHwDecodeConfig(config, await this.getDevices())
: new RkmppSwDecodeConfig(config, await this.getDevices());
break;
}
default: {
throw new UnsupportedMediaTypeException(`${config.accel.toUpperCase()} acceleration is unsupported`);
}
}
if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) {
throw new UnsupportedMediaTypeException(
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${handler.getSupportedCodecs()}`,
);
}
return handler;
}
isSRGB(asset: AssetEntity): boolean {
const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {};
if (colorspace || profileDescription) {
@ -567,24 +488,29 @@ export class MediaService {
private async getDevices() {
if (!this.devices) {
this.devices = await this.storageRepository.readdir('/dev/dri');
try {
this.devices = await this.storageRepository.readdir('/dev/dri');
} catch {
this.logger.debug('No devices found in /dev/dri.');
this.devices = [];
}
}
return this.devices;
}
private async hasOpenCL() {
if (this.openCL === null) {
private async hasMaliOpenCL() {
if (this.maliOpenCL === undefined) {
try {
const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd');
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
this.openCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch {
this.logger.warn('OpenCL not available for transcoding, using CPU instead.');
this.openCL = false;
this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.');
this.maliOpenCL = false;
}
}
return this.openCL;
return this.maliOpenCL;
}
}

View file

@ -1,6 +1,4 @@
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 { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface';
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 { 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, () => {
let sut: PartnerService;
let partnerMock: Mocked<IPartnerRepository>;
@ -65,13 +24,13 @@ describe(PartnerService.name, () => {
describe('getAll', () => {
it("should return a list of partners with whom I've shared my library", async () => {
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);
});
it('should return a list of partners who have shared their libraries with me', async () => {
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);
});
});
@ -81,7 +40,7 @@ describe(PartnerService.name, () => {
partnerMock.get.mockResolvedValue(null);
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({
sharedById: authStub.admin.user.id,

View file

@ -25,7 +25,7 @@ export class PartnerService {
}
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> {
@ -44,7 +44,7 @@ export class PartnerService {
return partners
.filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users
.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> {
@ -52,10 +52,10 @@ export class PartnerService {
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
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"
const user = mapUser(
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,

View file

@ -149,7 +149,7 @@ describe(ServerInfoService.name, () => {
it('should respond the server features', async () => {
await expect(sut.getFeatures()).resolves.toEqual({
smartSearch: true,
duplicateDetection: false,
duplicateDetection: true,
facialRecognition: true,
map: true,
reverseGeocoding: true,

View file

@ -81,8 +81,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
modelName: 'ViT-B-32__openai',
},
duplicateDetection: {
enabled: false,
maxDistance: 0.03,
enabled: true,
maxDistance: 0.0155,
},
facialRecognition: {
enabled: true,

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 {
BadRequestException,
ForbiddenException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { UpdateUserDto, mapUser } from 'src/dtos/user.dto';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { UserEntity } 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';
@ -63,13 +57,13 @@ describe(UserService.name, () => {
describe('getAll', () => {
it('should get all users', async () => {
userMock.getList.mockResolvedValue([userStub.admin]);
await expect(sut.getAll(authStub.admin, false)).resolves.toEqual([
await expect(sut.search()).resolves.toEqual([
expect.objectContaining({
id: authStub.admin.user.id,
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 () => {
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 });
});
});
describe('getMe', () => {
it("should get the auth user's info", async () => {
userMock.get.mockResolvedValue(userStub.admin);
await sut.getMe(authStub.admin);
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, {});
});
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(),
it("should get the auth user's info", () => {
const user = authStub.admin.user;
expect(sut.getMe(authStub.admin)).toMatchObject({
id: user.id,
email: user.email,
});
});
});

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 { SALT_ROUNDS } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import { AuthDto } from 'src/dtos/auth.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 { UserEntity, UserStatus } from 'src/entities/user.entity';
import { UserEntity } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@ -21,73 +21,30 @@ import { getPreferences, getPreferencesPartial } from 'src/utils/preferences';
@Injectable()
export class UserService {
private configCore: SystemConfigCore;
private userCore: UserCore;
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.userCore = UserCore.create(cryptoRepository, userRepository);
this.logger.setContext(UserService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
async listUsers(): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true });
async search(): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: false });
return users.map((user) => mapUser(user));
}
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: !isAll });
return users.map((user) => mapUser(user));
getMe(auth: AuthDto): UserAdminResponseDto {
return mapUserAdmin(auth.user);
}
async get(userId: string): Promise<UserResponseDto> {
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);
}
async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
// TODO replace with entire preferences object
if (dto.memoriesEnabled !== undefined || dto.avatarColor) {
const newPreferences = getPreferences(user);
@ -101,42 +58,40 @@ export class UserService {
delete dto.avatarColor;
}
await this.userRepository.upsertMetadata(dto.id, {
await this.userRepository.upsertMetadata(user.id, {
key: UserMetadataKey.PREFERENCES,
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> {
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 } });
}
async get(id: string): Promise<UserResponseDto> {
const user = await this.findOrFail(id, { withDeleted: false });
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> {
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path });

View file

@ -3,22 +3,84 @@ import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import {
AudioStreamInfo,
BitrateDistribution,
TranscodeOptions,
TranscodeCommand,
VideoCodecHWConfig,
VideoCodecSWConfig,
VideoStreamInfo,
} from 'src/interfaces/media.interface';
class BaseConfig implements VideoCodecSWConfig {
presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
constructor(protected config: SystemConfigFFmpegDto) {}
export class BaseConfig implements VideoCodecSWConfig {
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
protected constructor(protected config: SystemConfigFFmpegDto) {}
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
static create(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false): VideoCodecSWConfig {
if (config.accel === TranscodeHWAccel.DISABLED) {
return this.getSWCodecConfig(config);
}
return this.getHWCodecConfig(config, devices, hasMaliOpenCL);
}
private static getSWCodecConfig(config: SystemConfigFFmpegDto) {
switch (config.targetVideoCodec) {
case VideoCodec.H264: {
return new H264Config(config);
}
case VideoCodec.HEVC: {
return new HEVCConfig(config);
}
case VideoCodec.VP9: {
return new VP9Config(config);
}
case VideoCodec.AV1: {
return new AV1Config(config);
}
default: {
throw new Error(`Codec '${config.targetVideoCodec}' is unsupported`);
}
}
}
private static getHWCodecConfig(config: SystemConfigFFmpegDto, devices: string[] = [], hasMaliOpenCL = false) {
let handler: VideoCodecHWConfig;
switch (config.accel) {
case TranscodeHWAccel.NVENC: {
handler = config.accelDecode ? new NvencHwDecodeConfig(config) : new NvencSwDecodeConfig(config);
break;
}
case TranscodeHWAccel.QSV: {
handler = config.accelDecode ? new QsvHwDecodeConfig(config, devices) : new QsvSwDecodeConfig(config, devices);
break;
}
case TranscodeHWAccel.VAAPI: {
handler = new VAAPIConfig(config, devices);
break;
}
case TranscodeHWAccel.RKMPP: {
handler =
config.accelDecode && hasMaliOpenCL
? new RkmppHwDecodeConfig(config, devices)
: new RkmppSwDecodeConfig(config, devices);
break;
}
default: {
throw new Error(`${config.accel.toUpperCase()} acceleration is unsupported`);
}
}
if (!handler.getSupportedCodecs().includes(config.targetVideoCodec)) {
throw new Error(
`${config.accel.toUpperCase()} acceleration does not support codec '${config.targetVideoCodec.toUpperCase()}'. Supported codecs: ${handler.getSupportedCodecs()}`,
);
}
return handler;
}
getCommand(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = {
inputOptions: this.getBaseInputOptions(videoStream),
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
twoPass: this.eligibleForTwoPass(),
} as TranscodeOptions;
} as TranscodeCommand;
if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) {
const filters = this.getFilterOptions(videoStream);
if (filters.length > 0) {
@ -318,11 +380,20 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
}
export class ThumbnailConfig extends BaseConfig {
getBaseInputOptions(): string[] {
return ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'];
static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig {
return new ThumbnailConfig(config);
}
getBaseInputOptions(): string[] {
return ['-skip_frame nokey', '-sws_flags accurate_rnd+full_chroma_int'];
}
getBaseOutputOptions() {
return ['-frames:v 1'];
return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
}
getFilterOptions(videoStream: VideoStreamInfo): string[] {
return ['fps=12', 'thumbnail=12', `select=gt(scene\\,0.1)+gt(n\\,20)`, ...super.getFilterOptions(videoStream)];
}
getPresetOptions() {
@ -338,8 +409,7 @@ export class ThumbnailConfig extends BaseConfig {
}
getScaling(videoStream: VideoStreamInfo) {
let options = super.getScaling(videoStream);
options += ':flags=lanczos+accurate_rnd+bitexact+full_chroma_int';
let options = super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int';
if (!this.shouldToneMap(videoStream)) {
options += ':out_color_matrix=601:out_range=pc';
}
@ -534,8 +604,6 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
options.push(...this.getToneMapping(videoStream));
if (options.length > 0) {
options[options.length - 1] += ':format=nv12';
} else {
options.push('format=nv12');
}
return options;
}
@ -559,7 +627,7 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
}
getInputThreadOptions() {
return [`-threads ${this.config.threads <= 0 ? 1 : this.config.threads}`];
return [`-threads 1`];
}
getOutputThreadOptions() {
@ -649,7 +717,7 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
throw new Error('No QSV device found');
}
const options = ['-hwaccel qsv', '-hwaccel_output_format qsv', '-async_depth 4', '-threads 1'];
const options = ['-hwaccel qsv', '-hwaccel_output_format qsv', '-async_depth 4', ...this.getInputThreadOptions()];
const hwDevice = this.getPreferredHardwareDevice();
if (hwDevice) {
options.push(`-qsv_device ${hwDevice}`);
@ -694,6 +762,10 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
'hwmap=derive_device=qsv:reverse=1,format=qsv',
];
}
getInputThreadOptions() {
return [`-threads 1`];
}
}
export class VAAPIConfig extends BaseHWConfig {

View file

@ -154,7 +154,7 @@ export function validateCronExpression(expression: 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('.', ''));

View file

@ -51,6 +51,7 @@ module.exports = {
'unicorn/consistent-function-scoping': 'off',
'unicorn/prefer-top-level-await': 'off',
'unicorn/import-style': 'off',
'svelte/button-has-type': 'error',
// TODO: set recommended-type-checked and remove these rules
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',

View file

@ -1,7 +1,7 @@
<script lang="ts">
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
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 { createEventDispatcher } from 'svelte';
import Checkbox from '$lib/components/elements/checkbox.svelte';
@ -20,9 +20,9 @@
const handleDeleteUser = async () => {
try {
const { deletedAt } = await deleteUser({
const { deletedAt } = await deleteUserAdmin({
id: user.id,
deleteUserDto: { force: forceDelete },
userAdminDeleteDto: { force: forceDelete },
});
if (deletedAt == undefined) {

View file

@ -17,6 +17,7 @@
</script>
<button
type="button"
{disabled}
class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
color

View file

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

View file

@ -102,7 +102,7 @@
min={0.001}
max={0.1}
desc="Maximum distance between two images to consider them duplicates, ranging from 0.001-0.1. Higher values will detect more duplicates, but may result in false positives."
disabled={disabled || $featureFlags.duplicateDetection}
disabled={disabled || !$featureFlags.duplicateDetection}
isEdited={config.machineLearning.duplicateDetection.maxDistance !==
savedConfig.machineLearning.duplicateDetection.maxDistance}
/>

View file

@ -30,6 +30,7 @@
{#if group}
<div class="grid">
<button
type="button"
on:click={() => toggleAlbumGroupCollapsing(group.id)}
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
aria-expanded={!isCollapsed}

View file

@ -75,7 +75,7 @@
<div class="py-2">
<div class="text-gray text-sm mb-3">PEOPLE</div>
<div class="p-2">
<button class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
<button type="button" class="flex items-center gap-2" on:click={() => dispatch('showSelectSharedUser')}>
<div class="rounded-full w-10 h-10 border border-gray-500 flex items-center justify-center">
<div><Icon path={mdiPlus} size="25" /></div>
</div>

View file

@ -16,6 +16,7 @@
<th class="text-sm font-medium {option.columnStyle}">
<button
type="button"
class="rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
on:click={handleSort}
>

View file

@ -41,6 +41,7 @@
{@const isCollapsed = isAlbumGroupCollapsed($albumViewSettings, albumGroup.id)}
{@const iconRotation = isCollapsed ? 'rotate-0' : 'rotate-90'}
<button
type="button"
on:click={() => toggleAlbumGroupCollapsing(albumGroup.id)}
class="flex w-full mt-4 rounded-md"
aria-expanded={!isCollapsed}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import {
getMyUserInfo,
getMyUser,
removeUserFromAlbum,
type AlbumResponseDto,
type UserResponseDto,
@ -36,7 +36,7 @@
onMount(async () => {
try {
currentUser = await getMyUserInfo();
currentUser = await getMyUser();
} catch (error) {
handleError(error, 'Unable to refresh user');
}
@ -141,6 +141,7 @@
</div>
{:else if user.id == currentUser?.id}
<button
type="button"
on:click={() => (selectedRemoveUser = user)}
class="text-sm font-medium text-immich-primary transition-colors hover:text-immich-primary/75 dark:text-immich-dark-primary"
>Leave</button

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Dropdown from '$lib/components/elements/dropdown.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
@ -7,7 +6,7 @@
import {
AlbumUserRole,
getAllSharedLinks,
getAllUsers,
searchUsers,
type AlbumResponseDto,
type AlbumUserAddDto,
type SharedLinkResponseDto,
@ -36,10 +35,10 @@
let sharedLinks: SharedLinkResponseDto[] = [];
onMount(async () => {
await getSharedLinks();
const data = await getAllUsers({ isAll: false });
const data = await searchUsers();
// remove invalid users
users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId));
// remove album owner
users = data.filter((user) => user.id !== album.ownerId);
// Remove the existed shared users from the album
for (const sharedUser of album.albumUsers) {
@ -122,7 +121,11 @@
{#each users as user}
{#if !Object.keys(selectedUsers).includes(user.id)}
<div class="flex place-items-center transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl">
<button on:click={() => handleToggle(user)} class="flex w-full place-items-center gap-4 p-4">
<button
type="button"
on:click={() => handleToggle(user)}
class="flex w-full place-items-center gap-4 p-4"
>
<UserAvatar {user} size="md" />
<div class="text-left flex-grow">
<p class="text-immich-fg dark:text-immich-dark-fg">
@ -160,6 +163,7 @@
<div id="shared-buttons" class="mt-4 flex place-content-center place-items-center justify-around">
<button
type="button"
class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer"
on:click={() => dispatch('share')}
>
@ -168,13 +172,13 @@
</button>
{#if sharedLinks.length}
<button
<a
href={AppRoute.SHARED_LINKS}
class="flex flex-col place-content-center place-items-center gap-2 hover:cursor-pointer"
on:click={() => goto(AppRoute.SHARED_LINKS)}
>
<Icon path={mdiShareCircle} size={24} />
<p class="text-sm">View links</p>
</button>
</a>
{/if}
</div>
</FullScreenModal>

View file

@ -18,12 +18,12 @@
<div
class="w-full h-14 flex p-4 text-white items-center justify-center rounded-full gap-4 bg-immich-dark-bg bg-opacity-60"
>
<button class={disabled ? 'cursor-not-allowed' : ''} on:click={() => dispatch('favorite')} {disabled}>
<button type="button" class={disabled ? 'cursor-not-allowed' : ''} on:click={() => dispatch('favorite')} {disabled}>
<div class="items-center justify-center">
<Icon path={isLiked ? mdiHeart : mdiHeartOutline} size={24} />
</div>
</button>
<button on:click={() => dispatch('openActivityTab')}>
<button type="button" on:click={() => dispatch('openActivityTab')}>
<div class="flex gap-2 items-center justify-center">
<Icon path={mdiCommentOutline} class="scale-x-[-1]" size={24} />
{#if numberOfComments}

View file

@ -200,6 +200,7 @@
<div>
{#if showDeleteReaction[index]}
<button
type="button"
class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-3 px-6 text-left text-sm font-medium text-immich-fg hover:bg-red-300 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg dark:hover:bg-red-100 transition-colors"
use:clickOutside
on:outclick={() => (showDeleteReaction[index] = false)}
@ -252,6 +253,7 @@
<div>
{#if showDeleteReaction[index]}
<button
type="button"
class="absolute right-6 rounded-xl items-center bg-gray-300 dark:bg-slate-100 py-3 px-6 text-left text-sm font-medium text-immich-fg hover:bg-red-300 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg dark:hover:bg-red-100 transition-colors"
use:clickOutside
on:outclick={() => (showDeleteReaction[index] = false)}

View file

@ -28,6 +28,7 @@
</script>
<button
type="button"
on:click={() => dispatch('album')}
class="flex w-full gap-4 px-6 py-2 text-left transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
>

View file

@ -3,13 +3,11 @@
export let label: string;
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="my-auto group hover:cursor-pointer" on:click={onClick}>
<button
class="mx-4 rounded-full p-3 text-gray-500 transition group-hover:bg-gray-500 group-hover:text-white"
aria-label={label}
>
<slot />
</button>
</div>
<button
type="button"
class="my-auto mx-4 rounded-full p-3 text-gray-500 transition hover:bg-gray-500 hover:text-white"
aria-label={label}
on:click={onClick}
>
<slot />
</button>

View file

@ -106,6 +106,7 @@
<!-- Select asset button -->
{#if !readonly && (mouseOver || selected || selectionCandidate)}
<button
type="button"
on:click={onIconClickedHandler}
class="absolute p-2 focus:outline-none"
class:cursor-not-allowed={disabled}

View file

@ -93,6 +93,7 @@
{@const renderedOption = renderOption(option)}
{@const buttonStyle = renderedOption.disabled ? '' : 'transition-all hover:bg-gray-300 dark:hover:bg-gray-800'}
<button
type="button"
class="grid grid-cols-[36px,1fr] place-items-center p-2 disabled:opacity-40 {buttonStyle}"
disabled={renderedOption.disabled}
on:click={() => !renderedOption.disabled && handleSelectOption(option)}

View file

@ -7,6 +7,7 @@
<div class="flex bg-gray-200 dark:bg-immich-dark-gray rounded-2xl h-full">
{#each filters as filter, index}
<button
type="button"
class="text-sm px-4 {filter === selected
? 'dark:bg-gray-700 bg-gray-300'
: 'dark:hover:bg-gray-800 hover:bg-gray-300'} {index === 0 ? 'rounded-l-2xl' : ''} {index ===

View file

@ -100,7 +100,7 @@
{#each showPeople as person (person.id)}
{#if person.id !== editedPerson?.id}
<div class="w-fit">
<button class="w-[90px]" on:click={() => onReassign(person)}>
<button type="button" class="w-[90px]" on:click={() => onReassign(person)}>
<div class="relative">
<ImageThumbnail
curve

View file

@ -21,6 +21,7 @@
</script>
<button
type="button"
class="relative rounded-lg transition-all"
on:click={handleOnClicked}
disabled={!selectable}

View file

@ -51,6 +51,7 @@
</div>
<button
type="button"
disabled={potentialMergePeople.length === 0}
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
on:click={() => {
@ -71,13 +72,13 @@
{:else}
<div class="grid w-full grid-cols-1 gap-2">
<div class="px-2">
<button on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
<button type="button" on:click={() => (choosePersonToMerge = false)}> <Icon path={mdiArrowLeft} /></button>
</div>
<div class="flex items-center justify-center">
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
{#each potentialMergePeople as person (person.id)}
<div class="h-24 w-24 md:h-28 md:w-28">
<button class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
<button type="button" class="p-2 w-full" on:click={() => changePersonToMerge(person)}>
<ImageThumbnail
border={true}
circle

View file

@ -2,9 +2,8 @@
import { createEventDispatcher } from 'svelte';
import Button from '../elements/buttons/button.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 success: string;
@ -31,13 +30,7 @@
if (valid) {
errorMessage = '';
await updateUser({
updateUserDto: {
id: user.id,
password: String(password),
shouldChangePassword: false,
},
});
await updateMyUser({ userUpdateMeDto: { password: String(password) } });
dispatch('success');
}

View file

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

Some files were not shown because too many files have changed in this diff Show more