diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index e584dc55c..d8ae9c89c 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -36,10 +36,18 @@ export class MediaRequest { }) public media: Media; - @ManyToOne(() => User, (user) => user.requests, { eager: true }) + @ManyToOne(() => User, (user) => user.requests, { + eager: true, + onDelete: 'CASCADE', + }) public requestedBy: User; - @ManyToOne(() => User, { nullable: true, cascade: true, eager: true }) + @ManyToOne(() => User, { + nullable: true, + cascade: true, + eager: true, + onDelete: 'SET NULL', + }) public modifiedBy?: User; @CreateDateColumn() diff --git a/server/migration/1608217312474-AddUserRequestDeleteCascades.ts b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts new file mode 100644 index 000000000..ce3de8499 --- /dev/null +++ b/server/migration/1608217312474-AddUserRequestDeleteCascades.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserRequestDeleteCascades1608219049304 + implements MigrationInterface { + name = 'AddUserRequestDeleteCascades1608219049304'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/server/routes/user.ts b/server/routes/user.ts index 60753e9be..e6dd136a5 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -1,7 +1,9 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; +import { MediaRequest } from '../entity/MediaRequest'; import { User } from '../entity/User'; import { hasPermission, Permission } from '../lib/permissions'; +import logger from '../logger'; const router = Router(); @@ -94,13 +96,49 @@ router.delete<{ id: string }>('/:id', async (req, res, next) => { try { const userRepository = getRepository(User); - const user = await userRepository.findOneOrFail({ + const user = await userRepository.findOne({ where: { id: Number(req.params.id) }, + relations: ['requests'], }); + + if (!user) { + return next({ status: 404, message: 'User not found' }); + } + + if (user.id === 1) { + return next({ status: 405, message: 'This account cannot be deleted.' }); + } + + if (user.hasPermission(Permission.ADMIN)) { + return next({ + status: 405, + message: 'You cannot delete users with administrative privileges.', + }); + } + + const requestRepository = getRepository(MediaRequest); + + /** + * Requests are usually deleted through a cascade constraint. Those however, do + * not trigger the removal event so listeners to not run and the parent Media + * will not be updated back to unknown for titles that were still pending. So + * we manually remove all requests from the user here so the parent media's + * properly reflect the change. + */ + await requestRepository.remove(user.requests); + await userRepository.delete(user.id); return res.status(200).json(user.filter()); } catch (e) { - next({ status: 404, message: 'User not found' }); + logger.error('Something went wrong deleting a user', { + label: 'API', + userId: req.params.id, + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Something went wrong deleting the user', + }); } }); diff --git a/src/components/Common/Modal/index.tsx b/src/components/Common/Modal/index.tsx index beb8ff8ee..9420b85ea 100644 --- a/src/components/Common/Modal/index.tsx +++ b/src/components/Common/Modal/index.tsx @@ -8,7 +8,7 @@ import { useIntl } from 'react-intl'; import globalMessages from '../../../i18n/globalMessages'; import Transition from '../../Transition'; -interface ModalProps extends React.HTMLAttributes { +interface ModalProps { title?: string; onCancel?: (e?: MouseEvent) => void; onOk?: (e?: MouseEvent) => void; diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index e6b8135dd..37b8752dd 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import useSWR from 'swr'; import LoadingSpinner from '../Common/LoadingSpinner'; import type { User } from '../../../server/entity/User'; @@ -10,6 +10,11 @@ import { Permission } from '../../hooks/useUser'; import { useRouter } from 'next/router'; import Header from '../Common/Header'; import Table from '../Common/Table'; +import Transition from '../Transition'; +import Modal from '../Common/Modal'; +import axios from 'axios'; +import { useToasts } from 'react-toast-notifications'; +import globalMessages from '../../i18n/globalMessages'; const messages = defineMessages({ userlist: 'User List', @@ -24,12 +29,46 @@ const messages = defineMessages({ admin: 'Admin', user: 'User', plexuser: 'Plex User', + deleteuser: 'Delete User', + userdeleted: 'User deleted', + userdeleteerror: 'Something went wrong deleting the user', + deleteconfirm: + 'Are you sure you want to delete this user? All existing request data from this user will be removed.', }); const UserList: React.FC = () => { const intl = useIntl(); const router = useRouter(); - const { data, error } = useSWR('/api/v1/user'); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR('/api/v1/user'); + const [isDeleting, setDeleting] = useState(false); + const [deleteModal, setDeleteModal] = useState<{ + isOpen: boolean; + user?: User; + }>({ + isOpen: false, + }); + + const deleteUser = async () => { + setDeleting(true); + + try { + await axios.delete(`/api/v1/user/${deleteModal.user?.id}`); + + addToast(intl.formatMessage(messages.userdeleted), { + autoDismiss: true, + appearance: 'success', + }); + setDeleteModal({ isOpen: false }); + } catch (e) { + addToast(intl.formatMessage(messages.userdeleteerror), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + revalidate(); + } + }; if (!data && !error) { return ; @@ -37,6 +76,46 @@ const UserList: React.FC = () => { return ( <> + + deleteUser()} + okText={ + isDeleting + ? intl.formatMessage(globalMessages.deleting) + : intl.formatMessage(globalMessages.delete) + } + okDisabled={isDeleting} + okButtonType="danger" + onCancel={() => setDeleteModal({ isOpen: false })} + title={intl.formatMessage(messages.deleteuser)} + iconSvg={ + + + + } + > + {intl.formatMessage(messages.deleteconfirm)} + +
{intl.formatMessage(messages.userlist)}
@@ -104,7 +183,11 @@ const UserList: React.FC = () => { > {intl.formatMessage(messages.edit)} - diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 448b2d9cb..16b8cc011 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -14,6 +14,7 @@ const globalMessages = defineMessages({ approve: 'Approve', decline: 'Decline', delete: 'Delete', + deleting: 'Deleting…', }); export default globalMessages; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index f08ebcd5e..79276bfae 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -306,12 +306,16 @@ "components.UserList.admin": "Admin", "components.UserList.created": "Created", "components.UserList.delete": "Delete", + "components.UserList.deleteconfirm": "Are you sure you want to delete this user? All existing request data from this user will be removed.", + "components.UserList.deleteuser": "Delete User", "components.UserList.edit": "Edit", "components.UserList.lastupdated": "Last Updated", "components.UserList.plexuser": "Plex User", "components.UserList.role": "Role", "components.UserList.totalrequests": "Total Requests", "components.UserList.user": "User", + "components.UserList.userdeleted": "User deleted", + "components.UserList.userdeleteerror": "Something went wrong deleting the user", "components.UserList.userlist": "User List", "components.UserList.username": "Username", "components.UserList.usertype": "User Type", @@ -322,6 +326,7 @@ "i18n.decline": "Decline", "i18n.declined": "Declined", "i18n.delete": "Delete", + "i18n.deleting": "Deleting…", "i18n.movies": "Movies", "i18n.partiallyavailable": "Partially Available", "i18n.pending": "Pending",