mirror of
https://github.com/immich-app/immich.git
synced 2025-01-11 06:10:28 +02:00
feat(server,web): make user deletion delay configurable (#7663)
* feat(server,web): make user deletion delay configurable * alphabetical order * add min for user.deleteDelay in SettingInputField * make config.user.deleteDelay SettingInputField min consistent format * fix e2e test * update description on user delete delay
This commit is contained in:
parent
52dfe5fc92
commit
9125999d1a
@ -128,6 +128,9 @@ The default configuration looks like this:
|
|||||||
"theme": {
|
"theme": {
|
||||||
"customCss": ""
|
"customCss": ""
|
||||||
},
|
},
|
||||||
|
"user": {
|
||||||
|
"deleteDelay": 7
|
||||||
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"scan": {
|
"scan": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
@ -88,6 +88,7 @@ describe('/server-info', () => {
|
|||||||
loginPageMessage: '',
|
loginPageMessage: '',
|
||||||
oauthButtonText: 'Login with OAuth',
|
oauthButtonText: 'Login with OAuth',
|
||||||
trashDays: 30,
|
trashDays: 30,
|
||||||
|
userDeleteDelay: 7,
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
isOnboarded: false,
|
isOnboarded: false,
|
||||||
|
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
@ -160,6 +160,7 @@ doc/SystemConfigTemplateStorageOptionDto.md
|
|||||||
doc/SystemConfigThemeDto.md
|
doc/SystemConfigThemeDto.md
|
||||||
doc/SystemConfigThumbnailDto.md
|
doc/SystemConfigThumbnailDto.md
|
||||||
doc/SystemConfigTrashDto.md
|
doc/SystemConfigTrashDto.md
|
||||||
|
doc/SystemConfigUserDto.md
|
||||||
doc/TagApi.md
|
doc/TagApi.md
|
||||||
doc/TagResponseDto.md
|
doc/TagResponseDto.md
|
||||||
doc/TagTypeEnum.md
|
doc/TagTypeEnum.md
|
||||||
@ -357,6 +358,7 @@ lib/model/system_config_template_storage_option_dto.dart
|
|||||||
lib/model/system_config_theme_dto.dart
|
lib/model/system_config_theme_dto.dart
|
||||||
lib/model/system_config_thumbnail_dto.dart
|
lib/model/system_config_thumbnail_dto.dart
|
||||||
lib/model/system_config_trash_dto.dart
|
lib/model/system_config_trash_dto.dart
|
||||||
|
lib/model/system_config_user_dto.dart
|
||||||
lib/model/tag_response_dto.dart
|
lib/model/tag_response_dto.dart
|
||||||
lib/model/tag_type_enum.dart
|
lib/model/tag_type_enum.dart
|
||||||
lib/model/thumbnail_format.dart
|
lib/model/thumbnail_format.dart
|
||||||
@ -539,6 +541,7 @@ test/system_config_template_storage_option_dto_test.dart
|
|||||||
test/system_config_theme_dto_test.dart
|
test/system_config_theme_dto_test.dart
|
||||||
test/system_config_thumbnail_dto_test.dart
|
test/system_config_thumbnail_dto_test.dart
|
||||||
test/system_config_trash_dto_test.dart
|
test/system_config_trash_dto_test.dart
|
||||||
|
test/system_config_user_dto_test.dart
|
||||||
test/tag_api_test.dart
|
test/tag_api_test.dart
|
||||||
test/tag_response_dto_test.dart
|
test/tag_response_dto_test.dart
|
||||||
test/tag_type_enum_test.dart
|
test/tag_type_enum_test.dart
|
||||||
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/ServerConfigDto.md
generated
BIN
mobile/openapi/doc/ServerConfigDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigDto.md
generated
BIN
mobile/openapi/doc/SystemConfigDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SystemConfigUserDto.md
generated
Normal file
BIN
mobile/openapi/doc/SystemConfigUserDto.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/server_config_dto.dart
generated
BIN
mobile/openapi/lib/model/server_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_user_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/system_config_user_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/server_config_dto_test.dart
generated
BIN
mobile/openapi/test/server_config_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_user_dto_test.dart
generated
Normal file
BIN
mobile/openapi/test/system_config_user_dto_test.dart
generated
Normal file
Binary file not shown.
@ -9090,6 +9090,9 @@
|
|||||||
},
|
},
|
||||||
"trashDays": {
|
"trashDays": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"userDeleteDelay": {
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -9098,7 +9101,8 @@
|
|||||||
"isOnboarded",
|
"isOnboarded",
|
||||||
"loginPageMessage",
|
"loginPageMessage",
|
||||||
"oauthButtonText",
|
"oauthButtonText",
|
||||||
"trashDays"
|
"trashDays",
|
||||||
|
"userDeleteDelay"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@ -9661,6 +9665,9 @@
|
|||||||
},
|
},
|
||||||
"trash": {
|
"trash": {
|
||||||
"$ref": "#/components/schemas/SystemConfigTrashDto"
|
"$ref": "#/components/schemas/SystemConfigTrashDto"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"$ref": "#/components/schemas/SystemConfigUserDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -9678,7 +9685,8 @@
|
|||||||
"storageTemplate",
|
"storageTemplate",
|
||||||
"theme",
|
"theme",
|
||||||
"thumbnail",
|
"thumbnail",
|
||||||
"trash"
|
"trash",
|
||||||
|
"user"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
@ -10162,6 +10170,17 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SystemConfigUserDto": {
|
||||||
|
"properties": {
|
||||||
|
"deleteDelay": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"deleteDelay"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"TagResponseDto": {
|
"TagResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
|
@ -705,6 +705,7 @@ export type ServerConfigDto = {
|
|||||||
loginPageMessage: string;
|
loginPageMessage: string;
|
||||||
oauthButtonText: string;
|
oauthButtonText: string;
|
||||||
trashDays: number;
|
trashDays: number;
|
||||||
|
userDeleteDelay: number;
|
||||||
};
|
};
|
||||||
export type ServerFeaturesDto = {
|
export type ServerFeaturesDto = {
|
||||||
configFile: boolean;
|
configFile: boolean;
|
||||||
@ -918,6 +919,9 @@ export type SystemConfigTrashDto = {
|
|||||||
days: number;
|
days: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
export type SystemConfigUserDto = {
|
||||||
|
deleteDelay: number;
|
||||||
|
};
|
||||||
export type SystemConfigDto = {
|
export type SystemConfigDto = {
|
||||||
ffmpeg: SystemConfigFFmpegDto;
|
ffmpeg: SystemConfigFFmpegDto;
|
||||||
job: SystemConfigJobDto;
|
job: SystemConfigJobDto;
|
||||||
@ -934,6 +938,7 @@ export type SystemConfigDto = {
|
|||||||
theme: SystemConfigThemeDto;
|
theme: SystemConfigThemeDto;
|
||||||
thumbnail: SystemConfigThumbnailDto;
|
thumbnail: SystemConfigThumbnailDto;
|
||||||
trash: SystemConfigTrashDto;
|
trash: SystemConfigTrashDto;
|
||||||
|
user: SystemConfigUserDto;
|
||||||
};
|
};
|
||||||
export type SystemConfigTemplateStorageOptionDto = {
|
export type SystemConfigTemplateStorageOptionDto = {
|
||||||
dayOptions: string[];
|
dayOptions: string[];
|
||||||
|
@ -88,6 +88,8 @@ export class ServerConfigDto {
|
|||||||
loginPageMessage!: string;
|
loginPageMessage!: string;
|
||||||
@ApiProperty({ type: 'integer' })
|
@ApiProperty({ type: 'integer' })
|
||||||
trashDays!: number;
|
trashDays!: number;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
userDeleteDelay!: number;
|
||||||
isInitialized!: boolean;
|
isInitialized!: boolean;
|
||||||
isOnboarded!: boolean;
|
isOnboarded!: boolean;
|
||||||
externalDomain!: string;
|
externalDomain!: string;
|
||||||
|
@ -196,6 +196,7 @@ describe(ServerInfoService.name, () => {
|
|||||||
loginPageMessage: '',
|
loginPageMessage: '',
|
||||||
oauthButtonText: 'Login with OAuth',
|
oauthButtonText: 'Login with OAuth',
|
||||||
trashDays: 30,
|
trashDays: 30,
|
||||||
|
userDeleteDelay: 7,
|
||||||
isInitialized: undefined,
|
isInitialized: undefined,
|
||||||
isOnboarded: false,
|
isOnboarded: false,
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
|
@ -96,6 +96,7 @@ export class ServerInfoService {
|
|||||||
return {
|
return {
|
||||||
loginPageMessage: config.server.loginPageMessage,
|
loginPageMessage: config.server.loginPageMessage,
|
||||||
trashDays: config.trash.days,
|
trashDays: config.trash.days,
|
||||||
|
userDeleteDelay: config.user.deleteDelay,
|
||||||
oauthButtonText: config.oauth.buttonText,
|
oauthButtonText: config.oauth.buttonText,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isOnboarded: onboarding?.isOnboarded || false,
|
isOnboarded: onboarding?.isOnboarded || false,
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class SystemConfigUserDto {
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
deleteDelay!: number;
|
||||||
|
}
|
@ -16,6 +16,7 @@ import { SystemConfigStorageTemplateDto } from './system-config-storage-template
|
|||||||
import { SystemConfigThemeDto } from './system-config-theme.dto';
|
import { SystemConfigThemeDto } from './system-config-theme.dto';
|
||||||
import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto';
|
import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto';
|
||||||
import { SystemConfigTrashDto } from './system-config-trash.dto';
|
import { SystemConfigTrashDto } from './system-config-trash.dto';
|
||||||
|
import { SystemConfigUserDto } from './system-config-user.dto';
|
||||||
|
|
||||||
export class SystemConfigDto implements SystemConfig {
|
export class SystemConfigDto implements SystemConfig {
|
||||||
@Type(() => SystemConfigFFmpegDto)
|
@Type(() => SystemConfigFFmpegDto)
|
||||||
@ -92,6 +93,11 @@ export class SystemConfigDto implements SystemConfig {
|
|||||||
@ValidateNested()
|
@ValidateNested()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
server!: SystemConfigServerDto;
|
server!: SystemConfigServerDto;
|
||||||
|
|
||||||
|
@Type(() => SystemConfigUserDto)
|
||||||
|
@ValidateNested()
|
||||||
|
@IsObject()
|
||||||
|
user!: SystemConfigUserDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapConfig(config: SystemConfig): SystemConfigDto {
|
export function mapConfig(config: SystemConfig): SystemConfigDto {
|
||||||
|
@ -140,6 +140,9 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
loginPageMessage: '',
|
loginPageMessage: '',
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
deleteDelay: 7,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export enum FeatureFlag {
|
export enum FeatureFlag {
|
||||||
|
@ -23,6 +23,7 @@ const updates: SystemConfigEntity[] = [
|
|||||||
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
|
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
|
||||||
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
||||||
{ key: SystemConfigKey.TRASH_DAYS, value: 10 },
|
{ key: SystemConfigKey.TRASH_DAYS, value: 10 },
|
||||||
|
{ key: SystemConfigKey.USER_DELETE_DELAY, value: 15 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const updatedConfig = Object.freeze<SystemConfig>({
|
const updatedConfig = Object.freeze<SystemConfig>({
|
||||||
@ -140,6 +141,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
deleteDelay: 15,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(SystemConfigService.name, () => {
|
describe(SystemConfigService.name, () => {
|
||||||
@ -199,6 +203,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
|
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
|
||||||
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
||||||
{ key: SystemConfigKey.TRASH_DAYS, value: 10 },
|
{ key: SystemConfigKey.TRASH_DAYS, value: 10 },
|
||||||
|
{ key: SystemConfigKey.USER_DELETE_DELAY, value: 15 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
|
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
|
||||||
@ -206,7 +211,12 @@ describe(SystemConfigService.name, () => {
|
|||||||
|
|
||||||
it('should load the config from a file', async () => {
|
it('should load the config from a file', async () => {
|
||||||
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
|
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
|
||||||
const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true }, trash: { days: 10 } };
|
const partialConfig = {
|
||||||
|
ffmpeg: { crf: 30 },
|
||||||
|
oauth: { autoLaunch: true },
|
||||||
|
trash: { days: 10 },
|
||||||
|
user: { deleteDelay: 15 },
|
||||||
|
};
|
||||||
configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
|
configMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
|
||||||
|
|
||||||
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
|
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
|
||||||
|
@ -13,7 +13,9 @@ import {
|
|||||||
newJobRepositoryMock,
|
newJobRepositoryMock,
|
||||||
newLibraryRepositoryMock,
|
newLibraryRepositoryMock,
|
||||||
newStorageRepositoryMock,
|
newStorageRepositoryMock,
|
||||||
|
newSystemConfigRepositoryMock,
|
||||||
newUserRepositoryMock,
|
newUserRepositoryMock,
|
||||||
|
systemConfigStub,
|
||||||
userStub,
|
userStub,
|
||||||
} from '@test';
|
} from '@test';
|
||||||
import { when } from 'jest-when';
|
import { when } from 'jest-when';
|
||||||
@ -26,6 +28,7 @@ import {
|
|||||||
IJobRepository,
|
IJobRepository,
|
||||||
ILibraryRepository,
|
ILibraryRepository,
|
||||||
IStorageRepository,
|
IStorageRepository,
|
||||||
|
ISystemConfigRepository,
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
@ -48,17 +51,28 @@ describe(UserService.name, () => {
|
|||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
let libraryMock: jest.Mocked<ILibraryRepository>;
|
let libraryMock: jest.Mocked<ILibraryRepository>;
|
||||||
let storageMock: jest.Mocked<IStorageRepository>;
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
|
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
albumMock = newAlbumRepositoryMock();
|
albumMock = newAlbumRepositoryMock();
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
|
configMock = newSystemConfigRepositoryMock();
|
||||||
cryptoRepositoryMock = newCryptoRepositoryMock();
|
cryptoRepositoryMock = newCryptoRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
libraryMock = newLibraryRepositoryMock();
|
libraryMock = newLibraryRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
userMock = newUserRepositoryMock();
|
userMock = newUserRepositoryMock();
|
||||||
|
|
||||||
sut = new UserService(albumMock, assetMock, cryptoRepositoryMock, jobMock, libraryMock, storageMock, userMock);
|
sut = new UserService(
|
||||||
|
albumMock,
|
||||||
|
assetMock,
|
||||||
|
cryptoRepositoryMock,
|
||||||
|
jobMock,
|
||||||
|
libraryMock,
|
||||||
|
storageMock,
|
||||||
|
configMock,
|
||||||
|
userMock,
|
||||||
|
);
|
||||||
|
|
||||||
when(userMock.get).calledWith(authStub.admin.user.id, {}).mockResolvedValue(userStub.admin);
|
when(userMock.get).calledWith(authStub.admin.user.id, {}).mockResolvedValue(userStub.admin);
|
||||||
when(userMock.get).calledWith(authStub.admin.user.id, { withDeleted: true }).mockResolvedValue(userStub.admin);
|
when(userMock.get).calledWith(authStub.admin.user.id, { withDeleted: true }).mockResolvedValue(userStub.admin);
|
||||||
@ -461,6 +475,22 @@ describe(UserService.name, () => {
|
|||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
|
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should skip users not ready for deletion - deleteDelay30', async () => {
|
||||||
|
configMock.load.mockResolvedValue(systemConfigStub.deleteDelay30);
|
||||||
|
userMock.getDeletedUsers.mockResolvedValue([
|
||||||
|
{},
|
||||||
|
{ deletedAt: undefined },
|
||||||
|
{ deletedAt: null },
|
||||||
|
{ deletedAt: makeDeletedAt(15) },
|
||||||
|
] as UserEntity[]);
|
||||||
|
|
||||||
|
await sut.handleUserDeleteCheck();
|
||||||
|
|
||||||
|
expect(userMock.getDeletedUsers).toHaveBeenCalled();
|
||||||
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
|
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should queue user ready for deletion', async () => {
|
it('should queue user ready for deletion', async () => {
|
||||||
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) };
|
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) };
|
||||||
userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
|
userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
|
||||||
@ -470,6 +500,16 @@ describe(UserService.name, () => {
|
|||||||
expect(userMock.getDeletedUsers).toHaveBeenCalled();
|
expect(userMock.getDeletedUsers).toHaveBeenCalled();
|
||||||
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]);
|
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should queue user ready for deletion - deleteDelay30', async () => {
|
||||||
|
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(31) };
|
||||||
|
userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
|
||||||
|
|
||||||
|
await sut.handleUserDeleteCheck();
|
||||||
|
|
||||||
|
expect(userMock.getDeletedUsers).toHaveBeenCalled();
|
||||||
|
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleUserDelete', () => {
|
describe('handleUserDelete', () => {
|
||||||
|
@ -13,16 +13,19 @@ import {
|
|||||||
IJobRepository,
|
IJobRepository,
|
||||||
ILibraryRepository,
|
ILibraryRepository,
|
||||||
IStorageRepository,
|
IStorageRepository,
|
||||||
|
ISystemConfigRepository,
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
UserFindOptions,
|
UserFindOptions,
|
||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
import { StorageCore, StorageFolder } from '../storage';
|
import { StorageCore, StorageFolder } from '../storage';
|
||||||
|
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||||
import { CreateUserDto, UpdateUserDto } from './dto';
|
import { CreateUserDto, UpdateUserDto } from './dto';
|
||||||
import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto';
|
import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto';
|
||||||
import { UserCore } from './user.core';
|
import { UserCore } from './user.core';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
private configCore: SystemConfigCore;
|
||||||
private logger = new ImmichLogger(UserService.name);
|
private logger = new ImmichLogger(UserService.name);
|
||||||
private userCore: UserCore;
|
private userCore: UserCore;
|
||||||
|
|
||||||
@ -33,9 +36,11 @@ export class UserService {
|
|||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
|
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||||
) {
|
) {
|
||||||
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
|
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
|
||||||
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
|
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
|
||||||
@ -140,22 +145,26 @@ export class UserService {
|
|||||||
|
|
||||||
async handleUserDeleteCheck() {
|
async handleUserDeleteCheck() {
|
||||||
const users = await this.userRepository.getDeletedUsers();
|
const users = await this.userRepository.getDeletedUsers();
|
||||||
|
const config = await this.configCore.getConfig();
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(
|
||||||
users.flatMap((user) =>
|
users.flatMap((user) =>
|
||||||
this.isReadyForDeletion(user) ? [{ name: JobName.USER_DELETION, data: { id: user.id } }] : [],
|
this.isReadyForDeletion(user, config.user.deleteDelay)
|
||||||
|
? [{ name: JobName.USER_DELETION, data: { id: user.id } }]
|
||||||
|
: [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleUserDelete({ id }: IEntityJob) {
|
async handleUserDelete({ id }: IEntityJob) {
|
||||||
|
const config = await this.configCore.getConfig();
|
||||||
const user = await this.userRepository.get(id, { withDeleted: true });
|
const user = await this.userRepository.get(id, { withDeleted: true });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// just for extra protection here
|
// just for extra protection here
|
||||||
if (!this.isReadyForDeletion(user)) {
|
if (!this.isReadyForDeletion(user, config.user.deleteDelay)) {
|
||||||
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
|
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -184,12 +193,12 @@ export class UserService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private isReadyForDeletion(user: UserEntity): boolean {
|
private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean {
|
||||||
if (!user.deletedAt) {
|
if (!user.deletedAt) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return DateTime.now().minus({ days: 7 }) > DateTime.fromJSDate(user.deletedAt);
|
return DateTime.now().minus({ days: deleteDelay }) > DateTime.fromJSDate(user.deletedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOrFail(id: string, options: UserFindOptions) {
|
private async findOrFail(id: string, options: UserFindOptions) {
|
||||||
|
@ -108,6 +108,8 @@ export enum SystemConfigKey {
|
|||||||
TRASH_DAYS = 'trash.days',
|
TRASH_DAYS = 'trash.days',
|
||||||
|
|
||||||
THEME_CUSTOM_CSS = 'theme.customCss',
|
THEME_CUSTOM_CSS = 'theme.customCss',
|
||||||
|
|
||||||
|
USER_DELETE_DELAY = 'user.deleteDelay',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TranscodePolicy {
|
export enum TranscodePolicy {
|
||||||
@ -276,4 +278,7 @@ export interface SystemConfig {
|
|||||||
externalDomain: string;
|
externalDomain: string;
|
||||||
loginPageMessage: string;
|
loginPageMessage: string;
|
||||||
};
|
};
|
||||||
|
user: {
|
||||||
|
deleteDelay: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
1
server/test/fixtures/system-config.stub.ts
vendored
1
server/test/fixtures/system-config.stub.ts
vendored
@ -27,6 +27,7 @@ export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
|
|||||||
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
|
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
|
||||||
{ key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 },
|
{ key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 },
|
||||||
],
|
],
|
||||||
|
deleteDelay30: [{ key: SystemConfigKey.USER_DELETE_DELAY, value: 30 }],
|
||||||
libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }],
|
libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }],
|
||||||
libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }],
|
libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }],
|
||||||
};
|
};
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { deleteUser, type UserResponseDto } from '@immich/sdk';
|
import { deleteUser, type UserResponseDto } from '@immich/sdk';
|
||||||
|
import { serverConfig } from '$lib/stores/server-config.store';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
export let user: UserResponseDto;
|
export let user: UserResponseDto;
|
||||||
@ -30,7 +31,7 @@
|
|||||||
<svelte:fragment slot="prompt">
|
<svelte:fragment slot="prompt">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<p>
|
<p>
|
||||||
<b>{user.name}</b>'s account and assets will be permanently deleted after 7 days.
|
<b>{user.name}</b>'s account and assets will be permanently deleted after {$serverConfig.userDeleteDelay} days.
|
||||||
</p>
|
</p>
|
||||||
<p>Are you sure you want to continue?</p>
|
<p>Are you sure you want to continue?</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk';
|
import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk';
|
||||||
|
import { loadConfig } from '$lib/stores/server-config.store';
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
import { createEventDispatcher, onMount } from 'svelte';
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
import type { SettingsEventType } from './admin-settings';
|
import type { SettingsEventType } from './admin-settings';
|
||||||
@ -35,6 +36,8 @@
|
|||||||
savedConfig = cloneDeep(newConfig);
|
savedConfig = cloneDeep(newConfig);
|
||||||
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
|
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
|
||||||
|
|
||||||
|
await loadConfig();
|
||||||
|
|
||||||
dispatch('save');
|
dispatch('save');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, 'Unable to save settings');
|
handleError(error, 'Unable to save settings');
|
||||||
|
@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type SystemConfigDto } from '@immich/sdk';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { SettingsEventType } from '../admin-settings';
|
||||||
|
|
||||||
|
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
|
||||||
|
import SettingInputField, {
|
||||||
|
SettingInputFieldType,
|
||||||
|
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||||
|
|
||||||
|
export let savedConfig: SystemConfigDto;
|
||||||
|
export let defaultConfig: SystemConfigDto;
|
||||||
|
export let config: SystemConfigDto; // this is the config that is being edited
|
||||||
|
export let disabled = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<SettingsEventType>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div in:fade={{ duration: 500 }}>
|
||||||
|
<form autocomplete="off" on:submit|preventDefault>
|
||||||
|
<div class="ml-4 mt-4 flex flex-col gap-4">
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
|
min={1}
|
||||||
|
label="DELETE DELAY"
|
||||||
|
desc="Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution."
|
||||||
|
bind:value={config.user.deleteDelay}
|
||||||
|
isEdited={config.user.deleteDelay !== savedConfig.user.deleteDelay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-4">
|
||||||
|
<SettingButtonsRow
|
||||||
|
on:reset={({ detail }) => dispatch('reset', { ...detail, configKeys: ['user'] })}
|
||||||
|
on:save={() => dispatch('save', { user: config.user })}
|
||||||
|
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
|
||||||
|
{disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -25,6 +25,7 @@ export const serverConfig = writable<ServerConfig>({
|
|||||||
oauthButtonText: '',
|
oauthButtonText: '',
|
||||||
loginPageMessage: '',
|
loginPageMessage: '',
|
||||||
trashDays: 30,
|
trashDays: 30,
|
||||||
|
userDeleteDelay: 7,
|
||||||
isInitialized: false,
|
isInitialized: false,
|
||||||
isOnboarded: false,
|
isOnboarded: false,
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte';
|
import ThemeSettings from '$lib/components/admin-page/settings/theme/theme-settings.svelte';
|
||||||
import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';
|
import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';
|
||||||
import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte';
|
import TrashSettings from '$lib/components/admin-page/settings/trash-settings/trash-settings.svelte';
|
||||||
|
import UserSettings from '$lib/components/admin-page/settings/user-settings/user-settings.svelte';
|
||||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
@ -45,7 +46,8 @@
|
|||||||
| typeof ThumbnailSettings
|
| typeof ThumbnailSettings
|
||||||
| typeof TrashSettings
|
| typeof TrashSettings
|
||||||
| typeof NewVersionCheckSettings
|
| typeof NewVersionCheckSettings
|
||||||
| typeof FFmpegSettings;
|
| typeof FFmpegSettings
|
||||||
|
| typeof UserSettings;
|
||||||
|
|
||||||
const downloadConfig = () => {
|
const downloadConfig = () => {
|
||||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
||||||
@ -134,6 +136,12 @@
|
|||||||
subtitle: 'Manage trash settings',
|
subtitle: 'Manage trash settings',
|
||||||
key: 'trash',
|
key: 'trash',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
item: UserSettings,
|
||||||
|
title: 'User Settings',
|
||||||
|
subtitle: 'Manage user settings',
|
||||||
|
key: 'user-settings',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
item: NewVersionCheckSettings,
|
item: NewVersionCheckSettings,
|
||||||
title: 'Version Check',
|
title: 'Version Check',
|
||||||
|
Loading…
Reference in New Issue
Block a user