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",
"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",
"invalidBody": "Invalid request body: {0}",
"inviteRequired": "Invitation required to sign up.",
@ -238,6 +246,10 @@
"srEditLabel": "Edit",
"adminUserLabel": "Admin user",
"normalUserLabel": "Normal user",
"delete": "Delete",
"deleteUser": "Delete user {0}",
"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.",

View File

@ -115,6 +115,14 @@
<td
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"
>Edit<span class="sr-only"
@ -130,10 +138,14 @@
</div>
</div>
</div>
<DeleteUserModal v-model="userToDelete" />
</div>
</template>
<script setup lang="ts">
import { useUsers } from "~/composables/users";
import type { User } from "~/prisma/client";
useHead({
title: "Users",
});
@ -142,5 +154,14 @@ definePageMeta({
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>

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)
credentials Json
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, mec])
}
@ -38,7 +38,7 @@ model APIToken {
name String
userId String?
user User? @relation(fields: [userId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
clientId String?
client Client? @relation(fields: [clientId], references: [id], onDelete: Cascade)
@ -62,7 +62,7 @@ model Session {
expiresAt DateTime
userId String
user User? @relation(fields: [userId], references: [id])
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
data Json // misc extra data
}

View File

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

View File

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

View File

@ -17,6 +17,6 @@ model Article {
imageObjectId String? // Object ID
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?
}

View File

@ -28,7 +28,7 @@ model Notification {
nonce String?
userId String
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
acls String[]
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.",
"user:read": "Fetch any user's information.",
"user:delete": "Delete a user.",
"news:read": "Read news articles.",
"news:create": "Create a new news article.",

View File

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