Adds delete user functionality in admin panel #86 (#110)

* #86 Adds delete user functionality in admin panel

* Removes unnecessary code

* Prevents current user from deleting itself
This commit is contained in:
Pacodastre
2025-06-08 05:49:11 +01:00
committed by GitHub
parent e32954ea7d
commit 60abc03091
13 changed files with 214 additions and 8 deletions

View File

@ -0,0 +1,75 @@
<template>
<ModalTemplate :model-value="!!user">
<template #default>
<div>
<DialogTitle
as="h3"
class="text-lg font-bold font-display text-zinc-100"
>
{{ $t("users.admin.deleteUser", [user?.username]) }}
</DialogTitle>
<p class="mt-1 text-sm text-zinc-400">
{{ $t("common.deleteConfirm", [user?.username]) }}
</p>
<p class="mt-2 text-sm font-bold text-red-500">
{{ $t("common.cannotUndo") }}
</p>
</div>
</template>
<template #buttons>
<LoadingButton
:loading="deleteLoading"
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteUser()"
>
{{ $t("delete") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => (user = undefined)"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { DialogTitle } from "@headlessui/vue";
import type { User } from "~/prisma/client";
const user = defineModel<User | undefined>();
const deleteLoading = ref(false);
const router = useRouter();
const { t } = useI18n();
async function deleteUser() {
try {
if (!user.value) return;
deleteLoading.value = true;
await $dropFetch(`/api/v1/admin/users/${user.value.id}`, {
method: "DELETE",
});
user.value = undefined;
await fetchUsers();
router.push("/admin/users");
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.admin.user.delete.title"),
description: t("errors.admin.user.delete.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally {
deleteLoading.value = false;
}
}
</script>

24
composables/users.ts Normal file
View File

@ -0,0 +1,24 @@
import type { SerializeObject } from "nitropack";
import type { User, AuthMec } from "~/prisma/client";
export const useUsers = () =>
useState<
| Array<
SerializeObject<
User & {
authMecs?: Array<{ id: string; mec: AuthMec }>;
}
>
>
| undefined
>("users", () => undefined);
export const fetchUsers = async () => {
const users = useUsers();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore forget why this ignor exists
const newValue: User[] = await $dropFetch("/api/v1/admin/users");
users.value = newValue;
return newValue;
};

View File

@ -118,6 +118,14 @@
"invalidInvite": "Invalid or expired invitation", "invalidInvite": "Invalid or expired invitation",
"usernameTaken": "Username already taken." "usernameTaken": "Username already taken."
}, },
"admin": {
"user": {
"delete": {
"desc": "Drop couldn't delete this user: {0}",
"title": "Failed to delete user"
}
}
},
"backHome": "{arrow} Back to home", "backHome": "{arrow} Back to home",
"invalidBody": "Invalid request body: {0}", "invalidBody": "Invalid request body: {0}",
"inviteRequired": "Invitation required to sign up.", "inviteRequired": "Invitation required to sign up.",
@ -238,6 +246,10 @@
"srEditLabel": "Edit", "srEditLabel": "Edit",
"adminUserLabel": "Admin user", "adminUserLabel": "Admin user",
"normalUserLabel": "Normal user", "normalUserLabel": "Normal user",
"delete": "Delete",
"deleteUser": "Delete user {0}",
"authentication": { "authentication": {
"title": "Authentication", "title": "Authentication",
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.", "description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",

View File

@ -115,6 +115,14 @@
<td <td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6" class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
> >
<button
v-if="user.id !== currentUser?.id"
class="px-2 py-1 rounded bg-red-900/50 backdrop-blur-sm transition text-sm/6 font-semibold text-red-400 hover:text-red-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
@click="() => setUserToDelete(user)"
>
{{ $t("users.admin.delete") }}
</button>
<!-- <!--
<NuxtLink to="#" class="text-blue-600 hover:text-blue-500" <NuxtLink to="#" class="text-blue-600 hover:text-blue-500"
>Edit<span class="sr-only" >Edit<span class="sr-only"
@ -130,10 +138,14 @@
</div> </div>
</div> </div>
</div> </div>
<DeleteUserModal v-model="userToDelete" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useUsers } from "~/composables/users";
import type { User } from "~/prisma/client";
useHead({ useHead({
title: "Users", title: "Users",
}); });
@ -142,5 +154,14 @@ definePageMeta({
layout: "admin", layout: "admin",
}); });
const users = await $dropFetch("/api/v1/admin/users"); const users = useUsers();
const currentUser = useUser();
if (!users.value) {
await fetchUsers();
}
const userToDelete = ref();
const setUserToDelete = (user: User) => (userToDelete.value = user);
</script> </script>

View File

@ -0,0 +1,41 @@
-- DropForeignKey
ALTER TABLE "APIToken" DROP CONSTRAINT "APIToken_userId_fkey";
-- DropForeignKey
ALTER TABLE "Article" DROP CONSTRAINT "Article_authorId_fkey";
-- DropForeignKey
ALTER TABLE "Client" DROP CONSTRAINT "Client_userId_fkey";
-- DropForeignKey
ALTER TABLE "Collection" DROP CONSTRAINT "Collection_userId_fkey";
-- DropForeignKey
ALTER TABLE "LinkedAuthMec" DROP CONSTRAINT "LinkedAuthMec_userId_fkey";
-- DropForeignKey
ALTER TABLE "Notification" DROP CONSTRAINT "Notification_userId_fkey";
-- DropForeignKey
ALTER TABLE "Session" DROP CONSTRAINT "Session_userId_fkey";
-- AddForeignKey
ALTER TABLE "LinkedAuthMec" ADD CONSTRAINT "LinkedAuthMec_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APIToken" ADD CONSTRAINT "APIToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Client" ADD CONSTRAINT "Client_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Collection" ADD CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -11,7 +11,7 @@ model LinkedAuthMec {
version Int @default(1) version Int @default(1)
credentials Json credentials Json
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, mec]) @@id([userId, mec])
} }
@ -38,7 +38,7 @@ model APIToken {
name String name String
userId String? userId String?
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
clientId String? clientId String?
client Client? @relation(fields: [clientId], references: [id], onDelete: Cascade) client Client? @relation(fields: [clientId], references: [id], onDelete: Cascade)
@ -62,7 +62,7 @@ model Session {
expiresAt DateTime expiresAt DateTime
userId String userId String
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
data Json // misc extra data data Json // misc extra data
} }

View File

@ -9,7 +9,7 @@ enum ClientCapabilities {
model Client { model Client {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
capabilities ClientCapabilities[] capabilities ClientCapabilities[]

View File

@ -4,7 +4,7 @@ model Collection {
isDefault Boolean @default(false) isDefault Boolean @default(false)
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entries CollectionEntry[] entries CollectionEntry[]
} }

View File

@ -17,6 +17,6 @@ model Article {
imageObjectId String? // Object ID imageObjectId String? // Object ID
publishedAt DateTime @default(now()) publishedAt DateTime @default(now())
author User? @relation(fields: [authorId], references: [id]) // Optional, if no user, it's a system post author User? @relation(fields: [authorId], references: [id], onDelete: Cascade) // Optional, if no user, it's a system post
authorId String? authorId String?
} }

View File

@ -28,7 +28,7 @@ model Notification {
nonce String? nonce String?
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
acls String[] acls String[]
created DateTime @default(now()) created DateTime @default(now())

View File

@ -0,0 +1,31 @@
import { defineEventHandler, createError } from "h3";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["user:delete"]);
if (!allowed)
throw createError({
statusCode: 403,
});
const userId = h3.context.params?.id;
if (!userId) {
throw createError({
statusCode: 400,
message: "No userId in route.",
});
}
if (userId === "system")
throw createError({
statusCode: 400,
statusMessage: "Cannot interact with system user.",
});
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user)
throw createError({ statusCode: 404, statusMessage: "User not found." });
await prisma.user.delete({ where: { id: userId } });
return { success: true };
});

View File

@ -76,6 +76,7 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"import:game:new": "Import a game.", "import:game:new": "Import a game.",
"user:read": "Fetch any user's information.", "user:read": "Fetch any user's information.",
"user:delete": "Delete a user.",
"news:read": "Read news articles.", "news:read": "Read news articles.",
"news:create": "Create a new news article.", "news:create": "Create a new news article.",

View File

@ -70,6 +70,7 @@ export const systemACLs = [
"import:game:new", "import:game:new",
"user:read", "user:read",
"user:delete",
"news:read", "news:read",
"news:create", "news:create",