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": { "theme": {
"customCss": "" "customCss": ""
}, },
"user": {
"deleteDelay": 7
},
"library": { "library": {
"scan": { "scan": {
"enabled": true, "enabled": true,

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ Name | Type | Description | Notes
**loginPageMessage** | **String** | | **loginPageMessage** | **String** | |
**oauthButtonText** | **String** | | **oauthButtonText** | **String** | |
**trashDays** | **int** | | **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) [[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) | | **theme** | [**SystemConfigThemeDto**](SystemConfigThemeDto.md) | |
**thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) | | **thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) | |
**trash** | [**SystemConfigTrashDto**](SystemConfigTrashDto.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) [[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_theme_dto.dart';
part 'model/system_config_thumbnail_dto.dart'; part 'model/system_config_thumbnail_dto.dart';
part 'model/system_config_trash_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_response_dto.dart';
part 'model/tag_type_enum.dart'; part 'model/tag_type_enum.dart';
part 'model/thumbnail_format.dart'; part 'model/thumbnail_format.dart';

View File

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

View File

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

View File

@ -28,6 +28,7 @@ class SystemConfigDto {
required this.theme, required this.theme,
required this.thumbnail, required this.thumbnail,
required this.trash, required this.trash,
required this.user,
}); });
SystemConfigFFmpegDto ffmpeg; SystemConfigFFmpegDto ffmpeg;
@ -60,6 +61,8 @@ class SystemConfigDto {
SystemConfigTrashDto trash; SystemConfigTrashDto trash;
SystemConfigUserDto user;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto && bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto &&
other.ffmpeg == ffmpeg && other.ffmpeg == ffmpeg &&
@ -76,7 +79,8 @@ class SystemConfigDto {
other.storageTemplate == storageTemplate && other.storageTemplate == storageTemplate &&
other.theme == theme && other.theme == theme &&
other.thumbnail == thumbnail && other.thumbnail == thumbnail &&
other.trash == trash; other.trash == trash &&
other.user == user;
@override @override
int get hashCode => int get hashCode =>
@ -95,10 +99,11 @@ class SystemConfigDto {
(storageTemplate.hashCode) + (storageTemplate.hashCode) +
(theme.hashCode) + (theme.hashCode) +
(thumbnail.hashCode) + (thumbnail.hashCode) +
(trash.hashCode); (trash.hashCode) +
(user.hashCode);
@override @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() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -117,6 +122,7 @@ class SystemConfigDto {
json[r'theme'] = this.theme; json[r'theme'] = this.theme;
json[r'thumbnail'] = this.thumbnail; json[r'thumbnail'] = this.thumbnail;
json[r'trash'] = this.trash; json[r'trash'] = this.trash;
json[r'user'] = this.user;
return json; return json;
} }
@ -143,6 +149,7 @@ class SystemConfigDto {
theme: SystemConfigThemeDto.fromJson(json[r'theme'])!, theme: SystemConfigThemeDto.fromJson(json[r'theme'])!,
thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!, thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!,
trash: SystemConfigTrashDto.fromJson(json[r'trash'])!, trash: SystemConfigTrashDto.fromJson(json[r'trash'])!,
user: SystemConfigUserDto.fromJson(json[r'user'])!,
); );
} }
return null; return null;
@ -205,6 +212,7 @@ class SystemConfigDto {
'theme', 'theme',
'thumbnail', 'thumbnail',
'trash', '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 // TODO
}); });
// int userDeleteDelay
test('to test the property `userDeleteDelay`', () async {
// TODO
});
}); });

View File

@ -91,6 +91,11 @@ void main() {
// TODO // 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": { "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": {

View File

@ -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[];

View File

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

View File

@ -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: '',

View File

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

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

View File

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

View File

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

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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');

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: '', oauthButtonText: '',
loginPageMessage: '', loginPageMessage: '',
trashDays: 30, trashDays: 30,
userDeleteDelay: 7,
isInitialized: false, isInitialized: false,
isOnboarded: false, isOnboarded: false,
externalDomain: '', externalDomain: '',

View File

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