feat(server,web): Delete and restore user from the admin portal (#935)

* delete and restore user from admin UI

* addressed review comments and fix e2e test

* added cron job to delete user, and some formatting changes

* addressed review comments

* adding missing queue registration
This commit is contained in:
Zeeshan Khan
2022-11-07 16:53:47 -05:00
committed by GitHub
parent 948ff5530c
commit fe4b307fe6
30 changed files with 804 additions and 59 deletions

View File

@@ -1575,6 +1575,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto
*/
'isAdmin': boolean;
/**
*
* @type {string}
* @memberof UserResponseDto
*/
'deletedAt': string | null;
}
/**
*
@@ -4711,6 +4717,43 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteUser: async (userId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'userId' is not null or undefined
assertParamExists('deleteUser', 'userId', userId)
const localVarPath = `/user/{userId}`
.replace(`{${"userId"}}`, encodeURIComponent(String(userId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {boolean} isAll
@@ -4870,6 +4913,43 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreUser: async (userId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'userId' is not null or undefined
assertParamExists('restoreUser', 'userId', userId)
const localVarPath = `/user/{userId}/restore`
.replace(`{${"userId"}}`, encodeURIComponent(String(userId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -4948,6 +5028,16 @@ export const UserApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.createUser(createUserDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteUser(userId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteUser(userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {boolean} isAll
@@ -4996,6 +5086,16 @@ export const UserApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getUserCount(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async restoreUser(userId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.restoreUser(userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {UpdateUserDto} updateUserDto
@@ -5034,6 +5134,15 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
createUser(createUserDto: CreateUserDto, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.createUser(createUserDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteUser(userId: string, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.deleteUser(userId, options).then((request) => request(axios, basePath));
},
/**
*
* @param {boolean} isAll
@@ -5077,6 +5186,15 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
getUserCount(options?: any): AxiosPromise<UserCountResponseDto> {
return localVarFp.getUserCount(options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreUser(userId: string, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.restoreUser(userId, options).then((request) => request(axios, basePath));
},
/**
*
* @param {UpdateUserDto} updateUserDto
@@ -5118,6 +5236,17 @@ export class UserApi extends BaseAPI {
return UserApiFp(this.configuration).createUser(createUserDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UserApi
*/
public deleteUser(userId: string, options?: AxiosRequestConfig) {
return UserApiFp(this.configuration).deleteUser(userId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {boolean} isAll
@@ -5171,6 +5300,17 @@ export class UserApi extends BaseAPI {
return UserApiFp(this.configuration).getUserCount(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UserApi
*/
public restoreUser(userId: string, options?: AxiosRequestConfig) {
return UserApiFp(this.configuration).restoreUser(userId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {UpdateUserDto} updateUserDto

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
export let user: UserResponseDto;
const dispatch = createEventDispatcher();
const deleteUser = async () => {
const deletedUser = await api.userApi.deleteUser(user.id);
if (deletedUser.data.deletedAt != null) dispatch('user-delete-success');
else dispatch('user-delete-fail');
};
</script>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
Confirm User Deletion
</h1>
</div>
<div>
<p class="ml-4 text-md py-5 text-center">
{user.firstName}
{user.lastName} account and assets along will be marked to delete completely after 7 days. are
you sure you want to proceed ?
</p>
<div class="flex w-full px-4 gap-4 mt-8">
<button
on:click={deleteUser}
class="flex-1 transition-colors bg-red-500 hover:bg-red-400 px-6 py-3 text-white rounded-full w-full font-medium"
>Confirm
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
export let user: UserResponseDto;
const dispatch = createEventDispatcher();
const restoreUser = async () => {
const restoredUser = await api.userApi.restoreUser(user.id);
if (restoredUser.data.deletedAt == null) dispatch('user-restore-success');
else dispatch('user-restore-fail');
};
</script>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
Restore User
</h1>
</div>
<div>
<p class="ml-4 text-md py-5 text-center">
{user.firstName}
{user.lastName} account will restored
</p>
<div class="flex w-full px-4 gap-4 mt-8">
<button
on:click={restoreUser}
class="flex-1 transition-colors bg-lime-600 hover:bg-lime-500 px-6 py-3 text-white rounded-full w-full font-medium"
>Confirm
</button>
</div>
</div>
</div>

View File

@@ -3,9 +3,21 @@
import { createEventDispatcher } from 'svelte';
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte';
import moment from 'moment';
export let allUsers: Array<UserResponseDto>;
const dispatch = createEventDispatcher();
const isDeleted = (user: UserResponseDto): boolean => {
return user.deletedAt != null;
};
const getDeleteDate = (user: UserResponseDto): string => {
return moment(user.deletedAt).add(7, 'days').format('LL');
};
</script>
<table class="text-left w-full my-5">
@@ -16,7 +28,7 @@
<th class="text-center w-1/4 font-medium text-sm">Email</th>
<th class="text-center w-1/4 font-medium text-sm">First name</th>
<th class="text-center w-1/4 font-medium text-sm">Last name</th>
<th class="text-center w-1/4 font-medium text-sm">Edit</th>
<th class="text-center w-1/4 font-medium text-sm">Action</th>
</tr>
</thead>
<tbody
@@ -25,21 +37,44 @@
{#each allUsers as user, i}
<tr
class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-bg ${
i % 2 == 0 ? 'bg-immich-gray dark:bg-[#e5e5e5]' : 'bg-immich-bg dark:bg-[#eeeeee]'
isDeleted(user)
? 'bg-red-50'
: i % 2 == 0
? 'bg-immich-gray dark:bg-[#e5e5e5]'
: 'bg-immich-bg dark:bg-[#eeeeee]'
}`}
>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis"
><button
on:click={() => {
dispatch('edit-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><PencilOutline size="20" /></button
></td
>
<td class="text-sm px-4 w-1/4 text-ellipsis">
{#if !isDeleted(user)}
<button
on:click={() => {
dispatch('edit-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><PencilOutline size="16" /></button
>
<button
on:click={() => {
dispatch('delete-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><TrashCanOutline size="16" /></button
>
{/if}
{#if isDeleted(user)}
<button
on:click={() => {
dispatch('restore-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
title={`scheduled removal on ${getDeleteDate(user)}`}
><DeleteRestore size="16" /></button
>
{/if}
</td>
</tr>
{/each}
</tbody>

View File

@@ -11,21 +11,25 @@
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialoge.svelte';
import StatusBox from '$lib/components/shared-components/status-box.svelte';
import type { PageData } from './$types';
import { api, ServerStatsResponseDto, UserResponseDto } from '@api';
import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
import ServerStatsPanel from '$lib/components/admin-page/server-stats/server-stats-panel.svelte';
import RestoreDialoge from '$lib/components/admin-page/restore-dialoge.svelte';
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
export let data: PageData;
let editUser: UserResponseDto;
let selectedUser: UserResponseDto;
let shouldShowEditUserForm = false;
let shouldShowCreateUserForm = false;
let shouldShowInfoPanel = false;
let shouldShowDeleteConfirmDialog = false;
let shouldShowRestoreDialog = false;
let serverStat: ServerStatsResponseDto;
const onButtonClicked = (buttonType: CustomEvent) => {
@@ -45,7 +49,7 @@
const editUserHandler = async (event: CustomEvent) => {
const { user } = event.detail;
editUser = user;
selectedUser = user;
shouldShowEditUserForm = true;
};
@@ -62,6 +66,43 @@
shouldShowInfoPanel = true;
};
const deleteUserHandler = async (event: CustomEvent) => {
const { user } = event.detail;
selectedUser = user;
shouldShowDeleteConfirmDialog = true;
};
const onUserDeleteSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowDeleteConfirmDialog = false;
};
const onUserDeleteFail = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowDeleteConfirmDialog = false;
};
const restoreUserHandler = async (event: CustomEvent) => {
const { user } = event.detail;
selectedUser = user;
shouldShowRestoreDialog = true;
};
const onUserRestoreSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowRestoreDialog = false;
};
const onUserRestoreFail = async () => {
// show fail dialog
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowRestoreDialog = false;
};
const getServerStats = async () => {
try {
const res = await api.serverInfoApi.getStats();
@@ -87,13 +128,33 @@
{#if shouldShowEditUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
<EditUserForm
user={editUser}
user={selectedUser}
on:edit-success={onEditUserSuccess}
on:reset-password-success={onEditPasswordSuccess}
/>
</FullScreenModal>
{/if}
{#if shouldShowDeleteConfirmDialog}
<FullScreenModal on:clickOutside={() => (shouldShowDeleteConfirmDialog = false)}>
<DeleteConfirmDialog
user={selectedUser}
on:user-delete-success={onUserDeleteSuccess}
on:user-delete-fail={onUserDeleteFail}
/>
</FullScreenModal>
{/if}
{#if shouldShowRestoreDialog}
<FullScreenModal on:clickOutside={() => (shouldShowRestoreDialog = false)}>
<RestoreDialoge
user={selectedUser}
on:user-restore-success={onUserRestoreSuccess}
on:user-restore-fail={onUserRestoreFail}
/>
</FullScreenModal>
{/if}
{#if shouldShowInfoPanel}
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
@@ -160,6 +221,8 @@
allUsers={data.allUsers}
on:create-user={() => (shouldShowCreateUserForm = true)}
on:edit-user={editUserHandler}
on:delete-user={deleteUserHandler}
on:restore-user={restoreUserHandler}
/>
{/if}
{#if selectedAction === AdminSideBarSelection.JOBS}