1
0
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:
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 188 additions and 10 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

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SystemConfigUserDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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