mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
* #86 Adds delete user functionality in admin panel * Removes unnecessary code * Prevents current user from deleting itself
This commit is contained in:
75
components/DeleteUserModal.vue
Normal file
75
components/DeleteUserModal.vue
Normal 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
24
composables/users.ts
Normal 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;
|
||||
};
|
||||
@ -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.",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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[]
|
||||
|
||||
|
||||
@ -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[]
|
||||
}
|
||||
|
||||
@ -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?
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
31
server/api/v1/admin/users/[id]/index.delete.ts
Normal file
31
server/api/v1/admin/users/[id]/index.delete.ts
Normal 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 };
|
||||
});
|
||||
@ -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.",
|
||||
|
||||
@ -70,6 +70,7 @@ export const systemACLs = [
|
||||
"import:game:new",
|
||||
|
||||
"user:read",
|
||||
"user:delete",
|
||||
|
||||
"news:read",
|
||||
"news:create",
|
||||
|
||||
Reference in New Issue
Block a user