mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 09:15:35 +03:00
feat(server,web): add force delete to immediately remove user (#7681)
* feat(server,web): add force delete to immediately remove user * update wording on force delete confirmation * fix force delete css * PR feedback * cleanup user service delete for force * adding user status column * some cleanup and tests * more test fixes * run npm run sql:generate * chore: cleanup and websocket * chore: linting * userRepository.restore * removed bad color class from delete-confirm-dialoge * additional confirmation for user force delete * shorten confirmation message --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
@@ -7,6 +7,10 @@
|
||||
|
||||
export let user: UserResponseDto;
|
||||
|
||||
let forceDelete = false;
|
||||
let deleteButtonDisabled = false;
|
||||
let userIdInput: string = '';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
success: void;
|
||||
fail: void;
|
||||
@@ -15,7 +19,11 @@
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
try {
|
||||
const { deletedAt } = await deleteUser({ id: user.id });
|
||||
const { deletedAt } = await deleteUser({
|
||||
id: user.id,
|
||||
deleteUserDto: { force: forceDelete },
|
||||
});
|
||||
|
||||
if (deletedAt == undefined) {
|
||||
dispatch('fail');
|
||||
} else {
|
||||
@@ -26,20 +34,68 @@
|
||||
dispatch('fail');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = (e: Event) => {
|
||||
userIdInput = (e.target as HTMLInputElement).value;
|
||||
deleteButtonDisabled = userIdInput != user.email;
|
||||
};
|
||||
</script>
|
||||
|
||||
<ConfirmDialogue
|
||||
title="Delete User"
|
||||
confirmText="Delete"
|
||||
confirmText={forceDelete ? 'Permanently Delete' : 'Delete'}
|
||||
onConfirm={handleDeleteUser}
|
||||
onClose={() => dispatch('cancel')}
|
||||
disabled={deleteButtonDisabled}
|
||||
>
|
||||
<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 {$serverConfig.userDeleteDelay} days.
|
||||
</p>
|
||||
<p>Are you sure you want to continue?</p>
|
||||
{#if forceDelete}
|
||||
<p>
|
||||
<b>{user.name}</b>'s account and assets will be queued for permanent deletion <b>immediately</b>.
|
||||
</p>
|
||||
{:else}
|
||||
<p>
|
||||
<b>{user.name}</b>'s account and assets will be scheduled for permanent deletion in {$serverConfig.userDeleteDelay}
|
||||
days.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-center m-4 gap-2">
|
||||
<label class="text-sm dark:text-immich-dark-fg" for="forceDelete">
|
||||
Queue user and assets for immediate deletion
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="forceDelete"
|
||||
type="checkbox"
|
||||
class="form-checkbox h-5 w-5"
|
||||
bind:checked={forceDelete}
|
||||
on:change={() => {
|
||||
deleteButtonDisabled = forceDelete;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if forceDelete}
|
||||
<p class="text-immich-error">
|
||||
WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be
|
||||
recovered.
|
||||
</p>
|
||||
|
||||
<p class="immich-form-label text-sm" id="confirm-user-desc">
|
||||
To confirm, type "{user.email}" below
|
||||
</p>
|
||||
|
||||
<input
|
||||
class="immich-form-input w-full pb-2"
|
||||
id="confirm-user-id"
|
||||
aria-describedby="confirm-user-desc"
|
||||
name="confirm-user-id"
|
||||
type="text"
|
||||
on:input={handleConfirm}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</ConfirmDialogue>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { restoreUser, type UserResponseDto } from '@immich/sdk';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
@@ -12,10 +13,15 @@
|
||||
}>();
|
||||
|
||||
const handleRestoreUser = async () => {
|
||||
const { deletedAt } = await restoreUser({ id: user.id });
|
||||
if (deletedAt == undefined) {
|
||||
dispatch('success');
|
||||
} else {
|
||||
try {
|
||||
const { deletedAt } = await restoreUser({ id: user.id });
|
||||
if (deletedAt == undefined) {
|
||||
dispatch('success');
|
||||
} else {
|
||||
dispatch('fail');
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to restore user');
|
||||
dispatch('fail');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ReleaseEvent {
|
||||
}
|
||||
export interface Events {
|
||||
on_upload_success: (asset: AssetResponseDto) => void;
|
||||
on_user_delete: (id: string) => void;
|
||||
on_asset_delete: (assetId: string) => void;
|
||||
on_asset_trash: (assetIds: string[]) => void;
|
||||
on_asset_update: (asset: AssetResponseDto) => void;
|
||||
|
||||
@@ -8,11 +8,18 @@
|
||||
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { serverConfig } from '$lib/stores/server-config.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { asByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getAllUsers, type UserResponseDto } from '@immich/sdk';
|
||||
import { UserStatus, getAllUsers, type UserResponseDto } from '@immich/sdk';
|
||||
import { mdiClose, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
@@ -26,13 +33,26 @@
|
||||
let shouldShowRestoreDialog = false;
|
||||
let selectedUser: UserResponseDto;
|
||||
|
||||
const refresh = async () => {
|
||||
allUsers = await getAllUsers({ isAll: false });
|
||||
};
|
||||
|
||||
const onDeleteSuccess = (userId: string) => {
|
||||
const user = allUsers.find(({ id }) => id === userId);
|
||||
if (user) {
|
||||
allUsers = allUsers.filter((user) => user.id !== userId);
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: `User ${user.email} has been successfully removed.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
allUsers = $page.data.allUsers;
|
||||
});
|
||||
|
||||
const isDeleted = (user: UserResponseDto): boolean => {
|
||||
return user.deletedAt != undefined;
|
||||
};
|
||||
return websocketEvents.on('on_user_delete', onDeleteSuccess);
|
||||
});
|
||||
|
||||
const deleteDateFormat: Intl.DateTimeFormatOptions = {
|
||||
month: 'long',
|
||||
@@ -40,14 +60,14 @@
|
||||
year: 'numeric',
|
||||
};
|
||||
|
||||
const getDeleteDate = (user: UserResponseDto): string => {
|
||||
let deletedAt = new Date(user.deletedAt ?? Date.now());
|
||||
deletedAt.setDate(deletedAt.getDate() + 7);
|
||||
return deletedAt.toLocaleString($locale, deleteDateFormat);
|
||||
const getDeleteDate = (deletedAt: string): string => {
|
||||
return DateTime.fromISO(deletedAt)
|
||||
.plus({ days: $serverConfig.userDeleteDelay })
|
||||
.toLocaleString(deleteDateFormat, { locale: $locale });
|
||||
};
|
||||
|
||||
const onUserCreated = async () => {
|
||||
allUsers = await getAllUsers({ isAll: false });
|
||||
await refresh();
|
||||
shouldShowCreateUserForm = false;
|
||||
};
|
||||
|
||||
@@ -57,12 +77,12 @@
|
||||
};
|
||||
|
||||
const onEditUserSuccess = async () => {
|
||||
allUsers = await getAllUsers({ isAll: false });
|
||||
await refresh();
|
||||
shouldShowEditUserForm = false;
|
||||
};
|
||||
|
||||
const onEditPasswordSuccess = async () => {
|
||||
allUsers = await getAllUsers({ isAll: false });
|
||||
await refresh();
|
||||
shouldShowEditUserForm = false;
|
||||
shouldShowInfoPanel = true;
|
||||
};
|
||||
@@ -72,13 +92,8 @@
|
||||
shouldShowDeleteConfirmDialog = true;
|
||||
};
|
||||
|
||||
const onUserDeleteSuccess = async () => {
|
||||
allUsers = await getAllUsers({ isAll: false });
|
||||
shouldShowDeleteConfirmDialog = false;
|
||||
};
|
||||
|
||||
const onUserDeleteFail = async () => {
|
||||
allUsers = await getAllUsers({ isAll: false });
|
||||
const onUserDelete = async () => {
|
||||
await refresh();
|
||||
shouldShowDeleteConfirmDialog = false;
|
||||
};
|
||||
|
||||
@@ -87,14 +102,8 @@
|
||||
shouldShowRestoreDialog = true;
|
||||
};
|
||||
|
||||
const onUserRestoreSuccess = async () => {
|
||||
allUsers = await getAllUsers({ isAll: false });
|
||||
shouldShowRestoreDialog = false;
|
||||
};
|
||||
|
||||
const onUserRestoreFail = async () => {
|
||||
// show fail dialog
|
||||
allUsers = await getAllUsers({ isAll: false });
|
||||
const onUserRestore = async () => {
|
||||
await refresh();
|
||||
shouldShowRestoreDialog = false;
|
||||
};
|
||||
</script>
|
||||
@@ -123,8 +132,8 @@
|
||||
{#if shouldShowDeleteConfirmDialog}
|
||||
<DeleteConfirmDialog
|
||||
user={selectedUser}
|
||||
on:success={onUserDeleteSuccess}
|
||||
on:fail={onUserDeleteFail}
|
||||
on:success={onUserDelete}
|
||||
on:fail={onUserDelete}
|
||||
on:cancel={() => (shouldShowDeleteConfirmDialog = false)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -132,8 +141,8 @@
|
||||
{#if shouldShowRestoreDialog}
|
||||
<RestoreDialogue
|
||||
user={selectedUser}
|
||||
on:success={onUserRestoreSuccess}
|
||||
on:fail={onUserRestoreFail}
|
||||
on:success={onUserRestore}
|
||||
on:fail={onUserRestore}
|
||||
on:cancel={() => (shouldShowRestoreDialog = false)}
|
||||
/>
|
||||
{/if}
|
||||
@@ -179,9 +188,7 @@
|
||||
{#if allUsers}
|
||||
{#each allUsers as immichUser, index}
|
||||
<tr
|
||||
class="flex h-[80px] overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {isDeleted(
|
||||
immichUser,
|
||||
)
|
||||
class="flex h-[80px] overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {immichUser.deletedAt
|
||||
? 'bg-red-300 dark:bg-red-900'
|
||||
: index % 2 == 0
|
||||
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
|
||||
@@ -201,7 +208,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm">
|
||||
{#if !isDeleted(immichUser)}
|
||||
{#if !immichUser.deletedAt}
|
||||
<button
|
||||
on:click={() => editUserHandler(immichUser)}
|
||||
class="rounded-full bg-immich-primary p-2 sm:p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700 max-sm:mb-1"
|
||||
@@ -217,11 +224,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if isDeleted(immichUser)}
|
||||
{#if immichUser.deletedAt && immichUser.status === UserStatus.Deleted}
|
||||
<button
|
||||
on:click={() => restoreUserHandler(immichUser)}
|
||||
class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
|
||||
title="scheduled removal on {getDeleteDate(immichUser)}"
|
||||
title="scheduled removal on {getDeleteDate(immichUser.deletedAt)}"
|
||||
>
|
||||
<Icon path={mdiDeleteRestore} size="16" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { UserAvatarColor, type UserResponseDto } from '@immich/sdk';
|
||||
import { UserAvatarColor, UserStatus, type UserResponseDto } from '@immich/sdk';
|
||||
import { Sync } from 'factory.ts';
|
||||
|
||||
export const userFactory = Sync.makeFactory<UserResponseDto>({
|
||||
@@ -18,4 +18,5 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
|
||||
avatarColor: UserAvatarColor.Primary,
|
||||
quotaUsageInBytes: 0,
|
||||
quotaSizeInBytes: null,
|
||||
status: UserStatus.Active,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user