Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions components/DeleteUserModal.vue
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions composables/users.ts
Original file line number Diff line number Diff line change
@@ -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;
};
12 changes: 12 additions & 0 deletions i18n/locales/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand 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.",
Expand Down
23 changes: 22 additions & 1 deletion pages/admin/users/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
});
Expand All @@ -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>
41 changes: 41 additions & 0 deletions prisma/migrations/20250608010030_delete_user_cascade/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions prisma/models/auth.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand All @@ -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)
Expand All @@ -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
}
2 changes: 1 addition & 1 deletion prisma/models/client.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]

Expand Down
2 changes: 1 addition & 1 deletion prisma/models/collection.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Expand Down
2 changes: 1 addition & 1 deletion prisma/models/news.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
2 changes: 1 addition & 1 deletion prisma/models/user.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
31 changes: 31 additions & 0 deletions server/api/v1/admin/users/[id]/index.delete.ts
Original file line number Diff line number Diff line change
@@ -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 };
});
1 change: 1 addition & 0 deletions server/internal/acls/descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
1 change: 1 addition & 0 deletions server/internal/acls/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export const systemACLs = [
"import:game:new",

"user:read",
"user:delete",

"news:read",
"news:create",
Expand Down