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:
Sam Holton 2024-03-06 00:45:40 -05:00 committed by GitHub
parent 52dfe5fc92
commit 9125999d1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 366 additions and 16 deletions

View File

@ -128,6 +128,9 @@ The default configuration looks like this:
"theme": {
"customCss": ""
},
"user": {
"deleteDelay": 7
},
"library": {
"scan": {
"enabled": true,

View File

@ -88,6 +88,7 @@ describe('/server-info', () => {
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
trashDays: 30,
userDeleteDelay: 7,
isInitialized: true,
externalDomain: '',
isOnboarded: false,

View File

@ -160,6 +160,7 @@ doc/SystemConfigTemplateStorageOptionDto.md
doc/SystemConfigThemeDto.md
doc/SystemConfigThumbnailDto.md
doc/SystemConfigTrashDto.md
doc/SystemConfigUserDto.md
doc/TagApi.md
doc/TagResponseDto.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_thumbnail_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_type_enum.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_thumbnail_dto_test.dart
test/system_config_trash_dto_test.dart
test/system_config_user_dto_test.dart
test/tag_api_test.dart
test/tag_response_dto_test.dart
test/tag_type_enum_test.dart

View File

@ -355,6 +355,7 @@ Class | Method | HTTP request | Description
- [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)
- [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md)
- [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
- [SystemConfigUserDto](doc//SystemConfigUserDto.md)
- [TagResponseDto](doc//TagResponseDto.md)
- [TagTypeEnum](doc//TagTypeEnum.md)
- [ThumbnailFormat](doc//ThumbnailFormat.md)

View File

@ -14,6 +14,7 @@ Name | Type | Description | Notes
**loginPageMessage** | **String** | |
**oauthButtonText** | **String** | |
**trashDays** | **int** | |
**userDeleteDelay** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -23,6 +23,7 @@ Name | Type | Description | Notes
**theme** | [**SystemConfigThemeDto**](SystemConfigThemeDto.md) | |
**thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) | |
**trash** | [**SystemConfigTrashDto**](SystemConfigTrashDto.md) | |
**user** | [**SystemConfigUserDto**](SystemConfigUserDto.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,15 @@
# openapi.model.SystemConfigUserDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**deleteDelay** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -190,6 +190,7 @@ part 'model/system_config_template_storage_option_dto.dart';
part 'model/system_config_theme_dto.dart';
part 'model/system_config_thumbnail_dto.dart';
part 'model/system_config_trash_dto.dart';
part 'model/system_config_user_dto.dart';
part 'model/tag_response_dto.dart';
part 'model/tag_type_enum.dart';
part 'model/thumbnail_format.dart';

View File

@ -462,6 +462,8 @@ class ApiClient {
return SystemConfigThumbnailDto.fromJson(value);
case 'SystemConfigTrashDto':
return SystemConfigTrashDto.fromJson(value);
case 'SystemConfigUserDto':
return SystemConfigUserDto.fromJson(value);
case 'TagResponseDto':
return TagResponseDto.fromJson(value);
case 'TagTypeEnum':

View File

@ -19,6 +19,7 @@ class ServerConfigDto {
required this.loginPageMessage,
required this.oauthButtonText,
required this.trashDays,
required this.userDeleteDelay,
});
String externalDomain;
@ -33,6 +34,8 @@ class ServerConfigDto {
int trashDays;
int userDeleteDelay;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto &&
other.externalDomain == externalDomain &&
@ -40,7 +43,8 @@ class ServerConfigDto {
other.isOnboarded == isOnboarded &&
other.loginPageMessage == loginPageMessage &&
other.oauthButtonText == oauthButtonText &&
other.trashDays == trashDays;
other.trashDays == trashDays &&
other.userDeleteDelay == userDeleteDelay;
@override
int get hashCode =>
@ -50,10 +54,11 @@ class ServerConfigDto {
(isOnboarded.hashCode) +
(loginPageMessage.hashCode) +
(oauthButtonText.hashCode) +
(trashDays.hashCode);
(trashDays.hashCode) +
(userDeleteDelay.hashCode);
@override
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays]';
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, oauthButtonText=$oauthButtonText, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -63,6 +68,7 @@ class ServerConfigDto {
json[r'loginPageMessage'] = this.loginPageMessage;
json[r'oauthButtonText'] = this.oauthButtonText;
json[r'trashDays'] = this.trashDays;
json[r'userDeleteDelay'] = this.userDeleteDelay;
return json;
}
@ -80,6 +86,7 @@ class ServerConfigDto {
loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
trashDays: mapValueOfType<int>(json, r'trashDays')!,
userDeleteDelay: mapValueOfType<int>(json, r'userDeleteDelay')!,
);
}
return null;
@ -133,6 +140,7 @@ class ServerConfigDto {
'loginPageMessage',
'oauthButtonText',
'trashDays',
'userDeleteDelay',
};
}

View File

@ -28,6 +28,7 @@ class SystemConfigDto {
required this.theme,
required this.thumbnail,
required this.trash,
required this.user,
});
SystemConfigFFmpegDto ffmpeg;
@ -60,6 +61,8 @@ class SystemConfigDto {
SystemConfigTrashDto trash;
SystemConfigUserDto user;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto &&
other.ffmpeg == ffmpeg &&
@ -76,7 +79,8 @@ class SystemConfigDto {
other.storageTemplate == storageTemplate &&
other.theme == theme &&
other.thumbnail == thumbnail &&
other.trash == trash;
other.trash == trash &&
other.user == user;
@override
int get hashCode =>
@ -95,10 +99,11 @@ class SystemConfigDto {
(storageTemplate.hashCode) +
(theme.hashCode) +
(thumbnail.hashCode) +
(trash.hashCode);
(trash.hashCode) +
(user.hashCode);
@override
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]';
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash, user=$user]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -117,6 +122,7 @@ class SystemConfigDto {
json[r'theme'] = this.theme;
json[r'thumbnail'] = this.thumbnail;
json[r'trash'] = this.trash;
json[r'user'] = this.user;
return json;
}
@ -143,6 +149,7 @@ class SystemConfigDto {
theme: SystemConfigThemeDto.fromJson(json[r'theme'])!,
thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!,
trash: SystemConfigTrashDto.fromJson(json[r'trash'])!,
user: SystemConfigUserDto.fromJson(json[r'user'])!,
);
}
return null;
@ -205,6 +212,7 @@ class SystemConfigDto {
'theme',
'thumbnail',
'trash',
'user',
};
}

View File

@ -0,0 +1,98 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SystemConfigUserDto {
/// Returns a new [SystemConfigUserDto] instance.
SystemConfigUserDto({
required this.deleteDelay,
});
int deleteDelay;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigUserDto &&
other.deleteDelay == deleteDelay;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(deleteDelay.hashCode);
@override
String toString() => 'SystemConfigUserDto[deleteDelay=$deleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'deleteDelay'] = this.deleteDelay;
return json;
}
/// Returns a new [SystemConfigUserDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigUserDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return SystemConfigUserDto(
deleteDelay: mapValueOfType<int>(json, r'deleteDelay')!,
);
}
return null;
}
static List<SystemConfigUserDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigUserDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigUserDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigUserDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigUserDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigUserDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigUserDto-objects as value to a dart map
static Map<String, List<SystemConfigUserDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigUserDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SystemConfigUserDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'deleteDelay',
};
}

View File

@ -46,6 +46,11 @@ void main() {
// TODO
});
// int userDeleteDelay
test('to test the property `userDeleteDelay`', () async {
// TODO
});
});

View File

@ -91,6 +91,11 @@ void main() {
// TODO
});
// SystemConfigUserDto user
test('to test the property `user`', () async {
// TODO
});
});

View File

@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for SystemConfigUserDto
void main() {
// final instance = SystemConfigUserDto();
group('test SystemConfigUserDto', () {
// int deleteDelay
test('to test the property `deleteDelay`', () async {
// TODO
});
});
}

View File

@ -9090,6 +9090,9 @@
},
"trashDays": {
"type": "integer"
},
"userDeleteDelay": {
"type": "integer"
}
},
"required": [
@ -9098,7 +9101,8 @@
"isOnboarded",
"loginPageMessage",
"oauthButtonText",
"trashDays"
"trashDays",
"userDeleteDelay"
],
"type": "object"
},
@ -9661,6 +9665,9 @@
},
"trash": {
"$ref": "#/components/schemas/SystemConfigTrashDto"
},
"user": {
"$ref": "#/components/schemas/SystemConfigUserDto"
}
},
"required": [
@ -9678,7 +9685,8 @@
"storageTemplate",
"theme",
"thumbnail",
"trash"
"trash",
"user"
],
"type": "object"
},
@ -10162,6 +10170,17 @@
],
"type": "object"
},
"SystemConfigUserDto": {
"properties": {
"deleteDelay": {
"type": "integer"
}
},
"required": [
"deleteDelay"
],
"type": "object"
},
"TagResponseDto": {
"properties": {
"id": {

View File

@ -705,6 +705,7 @@ export type ServerConfigDto = {
loginPageMessage: string;
oauthButtonText: string;
trashDays: number;
userDeleteDelay: number;
};
export type ServerFeaturesDto = {
configFile: boolean;
@ -918,6 +919,9 @@ export type SystemConfigTrashDto = {
days: number;
enabled: boolean;
};
export type SystemConfigUserDto = {
deleteDelay: number;
};
export type SystemConfigDto = {
ffmpeg: SystemConfigFFmpegDto;
job: SystemConfigJobDto;
@ -934,6 +938,7 @@ export type SystemConfigDto = {
theme: SystemConfigThemeDto;
thumbnail: SystemConfigThumbnailDto;
trash: SystemConfigTrashDto;
user: SystemConfigUserDto;
};
export type SystemConfigTemplateStorageOptionDto = {
dayOptions: string[];

View File

@ -88,6 +88,8 @@ export class ServerConfigDto {
loginPageMessage!: string;
@ApiProperty({ type: 'integer' })
trashDays!: number;
@ApiProperty({ type: 'integer' })
userDeleteDelay!: number;
isInitialized!: boolean;
isOnboarded!: boolean;
externalDomain!: string;

View File

@ -196,6 +196,7 @@ describe(ServerInfoService.name, () => {
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
trashDays: 30,
userDeleteDelay: 7,
isInitialized: undefined,
isOnboarded: false,
externalDomain: '',

View File

@ -96,6 +96,7 @@ export class ServerInfoService {
return {
loginPageMessage: config.server.loginPageMessage,
trashDays: config.trash.days,
userDeleteDelay: config.user.deleteDelay,
oauthButtonText: config.oauth.buttonText,
isInitialized,
isOnboarded: onboarding?.isOnboarded || false,

View File

@ -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;
}

View File

@ -16,6 +16,7 @@ import { SystemConfigStorageTemplateDto } from './system-config-storage-template
import { SystemConfigThemeDto } from './system-config-theme.dto';
import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto';
import { SystemConfigTrashDto } from './system-config-trash.dto';
import { SystemConfigUserDto } from './system-config-user.dto';
export class SystemConfigDto implements SystemConfig {
@Type(() => SystemConfigFFmpegDto)
@ -92,6 +93,11 @@ export class SystemConfigDto implements SystemConfig {
@ValidateNested()
@IsObject()
server!: SystemConfigServerDto;
@Type(() => SystemConfigUserDto)
@ValidateNested()
@IsObject()
user!: SystemConfigUserDto;
}
export function mapConfig(config: SystemConfig): SystemConfigDto {

View File

@ -140,6 +140,9 @@ export const defaults = Object.freeze<SystemConfig>({
externalDomain: '',
loginPageMessage: '',
},
user: {
deleteDelay: 7,
},
});
export enum FeatureFlag {

View File

@ -23,6 +23,7 @@ const updates: SystemConfigEntity[] = [
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
{ key: SystemConfigKey.TRASH_DAYS, value: 10 },
{ key: SystemConfigKey.USER_DELETE_DELAY, value: 15 },
];
const updatedConfig = Object.freeze<SystemConfig>({
@ -140,6 +141,9 @@ const updatedConfig = Object.freeze<SystemConfig>({
enabled: false,
},
},
user: {
deleteDelay: 15,
},
});
describe(SystemConfigService.name, () => {
@ -199,6 +203,7 @@ describe(SystemConfigService.name, () => {
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
{ key: SystemConfigKey.TRASH_DAYS, value: 10 },
{ key: SystemConfigKey.USER_DELETE_DELAY, value: 15 },
]);
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);
@ -206,7 +211,12 @@ describe(SystemConfigService.name, () => {
it('should load the config from a file', async () => {
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));
await expect(sut.getConfig()).resolves.toEqual(updatedConfig);

View File

@ -13,7 +13,9 @@ import {
newJobRepositoryMock,
newLibraryRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
systemConfigStub,
userStub,
} from '@test';
import { when } from 'jest-when';
@ -26,6 +28,7 @@ import {
IJobRepository,
ILibraryRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
} from '../repositories';
import { UpdateUserDto } from './dto/update-user.dto';
@ -48,17 +51,28 @@ describe(UserService.name, () => {
let jobMock: jest.Mocked<IJobRepository>;
let libraryMock: jest.Mocked<ILibraryRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
cryptoRepositoryMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
libraryMock = newLibraryRepositoryMock();
storageMock = newStorageRepositoryMock();
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, { withDeleted: true }).mockResolvedValue(userStub.admin);
@ -461,6 +475,22 @@ describe(UserService.name, () => {
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 () => {
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) };
userMock.getDeletedUsers.mockResolvedValue([user] as UserEntity[]);
@ -470,6 +500,16 @@ describe(UserService.name, () => {
expect(userMock.getDeletedUsers).toHaveBeenCalled();
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', () => {

View File

@ -13,16 +13,19 @@ import {
IJobRepository,
ILibraryRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
UserFindOptions,
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
import { SystemConfigCore } from '../system-config/system-config.core';
import { CreateUserDto, UpdateUserDto } from './dto';
import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto';
import { UserCore } from './user.core';
@Injectable()
export class UserService {
private configCore: SystemConfigCore;
private logger = new ImmichLogger(UserService.name);
private userCore: UserCore;
@ -33,9 +36,11 @@ export class UserService {
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
this.configCore = SystemConfigCore.create(configRepository);
}
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
@ -140,22 +145,26 @@ export class UserService {
async handleUserDeleteCheck() {
const users = await this.userRepository.getDeletedUsers();
const config = await this.configCore.getConfig();
await this.jobRepository.queueAll(
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;
}
async handleUserDelete({ id }: IEntityJob) {
const config = await this.configCore.getConfig();
const user = await this.userRepository.get(id, { withDeleted: true });
if (!user) {
return false;
}
// 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}`);
return false;
}
@ -184,12 +193,12 @@ export class UserService {
return true;
}
private isReadyForDeletion(user: UserEntity): boolean {
private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean {
if (!user.deletedAt) {
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) {

View File

@ -108,6 +108,8 @@ export enum SystemConfigKey {
TRASH_DAYS = 'trash.days',
THEME_CUSTOM_CSS = 'theme.customCss',
USER_DELETE_DELAY = 'user.deleteDelay',
}
export enum TranscodePolicy {
@ -276,4 +278,7 @@ export interface SystemConfig {
externalDomain: string;
loginPageMessage: string;
};
user: {
deleteDelay: number;
};
}

View File

@ -27,6 +27,7 @@ export const systemConfigStub: Record<string, SystemConfigEntity[]> = {
{ key: SystemConfigKey.OAUTH_AUTO_REGISTER, value: true },
{ key: SystemConfigKey.OAUTH_DEFAULT_STORAGE_QUOTA, value: 1 },
],
deleteDelay30: [{ key: SystemConfigKey.USER_DELETE_DELAY, value: 30 }],
libraryWatchEnabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: true }],
libraryWatchDisabled: [{ key: SystemConfigKey.LIBRARY_WATCH_ENABLED, value: false }],
};

View File

@ -2,6 +2,7 @@
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 { serverConfig } from '$lib/stores/server-config.store';
import { createEventDispatcher } from 'svelte';
export let user: UserResponseDto;
@ -30,7 +31,7 @@
<svelte:fragment slot="prompt">
<div class="flex flex-col gap-4">
<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>Are you sure you want to continue?</p>
</div>

View File

@ -7,6 +7,7 @@
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { getConfig, getConfigDefaults, updateConfig, type SystemConfigDto } from '@immich/sdk';
import { loadConfig } from '$lib/stores/server-config.store';
import { cloneDeep } from 'lodash-es';
import { createEventDispatcher, onMount } from 'svelte';
import type { SettingsEventType } from './admin-settings';
@ -35,6 +36,8 @@
savedConfig = cloneDeep(newConfig);
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
await loadConfig();
dispatch('save');
} catch (error) {
handleError(error, 'Unable to save settings');

View File

@ -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>

View File

@ -25,6 +25,7 @@ export const serverConfig = writable<ServerConfig>({
oauthButtonText: '',
loginPageMessage: '',
trashDays: 30,
userDeleteDelay: 7,
isInitialized: false,
isOnboarded: false,
externalDomain: '',

View File

@ -15,6 +15,7 @@
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 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 Icon from '$lib/components/elements/icon.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
@ -45,7 +46,8 @@
| typeof ThumbnailSettings
| typeof TrashSettings
| typeof NewVersionCheckSettings
| typeof FFmpegSettings;
| typeof FFmpegSettings
| typeof UserSettings;
const downloadConfig = () => {
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
@ -134,6 +136,12 @@
subtitle: 'Manage trash settings',
key: 'trash',
},
{
item: UserSettings,
title: 'User Settings',
subtitle: 'Manage user settings',
key: 'user-settings',
},
{
item: NewVersionCheckSettings,
title: 'Version Check',