From aa4632cab8a054bb08c10e487dd37211d2b231ac Mon Sep 17 00:00:00 2001
From: Pascal Syma
Date: Thu, 30 Jul 2026 23:13:38 +0200
Subject: [PATCH] feat(admin): add user role management with promotion feature
---
app/components/admin/UserRoles.vue | 190 ++++++++++++++++++++
app/lang/de.json | 17 +-
app/lang/en.json | 17 +-
app/pages/profile.vue | 1 +
server/api/admin/user/[userId]/role.post.ts | 53 ++++++
server/api/admin/user/index.get.ts | 31 ++++
server/api/admin/user/search.get.ts | 41 +++++
7 files changed, 348 insertions(+), 2 deletions(-)
create mode 100644 app/components/admin/UserRoles.vue
create mode 100644 server/api/admin/user/[userId]/role.post.ts
create mode 100644 server/api/admin/user/index.get.ts
create mode 100644 server/api/admin/user/search.get.ts
diff --git a/app/components/admin/UserRoles.vue b/app/components/admin/UserRoles.vue
new file mode 100644
index 0000000..5c4d19f
--- /dev/null
+++ b/app/components/admin/UserRoles.vue
@@ -0,0 +1,190 @@
+
+
+
+
+ {{ t('page.profile.admins_and_creators') }}
+
+
+
{{
+ t('page.profile.own_role_hint')
+ }}
+
+
+ {{ u.name }}
+ {{ u.email }}
+
+
+
+
{{
+ t('vuestic.loading')
+ }}
+
+
+
+
+ {{ t('page.profile.promote_user') }}
+
+
+
+
+ {{ t('page.profile.promote') }}
+
+
+
+
diff --git a/app/lang/de.json b/app/lang/de.json
index 7d6c4e1..77b8b65 100644
--- a/app/lang/de.json
+++ b/app/lang/de.json
@@ -41,6 +41,11 @@
"TEXT": "Freitext",
"WORD": "Ein Wort",
"CHAR": "Ein Zeichen"
+ },
+ "user_role": {
+ "ADMIN": "Admin",
+ "CREATOR": "Ersteller",
+ "USER": "Spieler"
}
},
"error": {
@@ -131,10 +136,20 @@
"filter_quest": "Filtere Bilder nach Quests"
},
"profile": {
+ "admins_and_creators": "Admins & Ersteller",
"new_password": "Neues Passwort",
"no_pictures": "Noch keine Bilder hochgeladen.",
"old_password": "Altes Passwort",
- "uploaded_pictures": "Hochgeladene Bilder"
+ "own_role_hint": "Du kannst deine eigene Rolle nicht ändern.",
+ "promote": "Befördern",
+ "promote_user": "Benutzer befördern",
+ "role": "Rolle",
+ "role_changed": "Rolle aktualisiert!",
+ "role_change_error": "Rolle konnte nicht geändert werden!",
+ "role_confirm": "Möchtest du die Rolle von {name} zu {role} ändern?",
+ "search_user": "Benutzer per E-Mail suchen...",
+ "uploaded_pictures": "Hochgeladene Bilder",
+ "user_management": "Benutzerverwaltung"
},
"quest": {
"already_finalized": "Antwort ist bereits finalisiert, keine Änderung erlaubt!",
diff --git a/app/lang/en.json b/app/lang/en.json
index 7618ab5..2d3a933 100644
--- a/app/lang/en.json
+++ b/app/lang/en.json
@@ -41,6 +41,11 @@
"TEXT": "Free text",
"WORD": "One word",
"CHAR": "One character"
+ },
+ "user_role": {
+ "ADMIN": "Admin",
+ "CREATOR": "Creator",
+ "USER": "Player"
}
},
"error": {
@@ -131,10 +136,20 @@
"filter_quest": "Filter pictures by quests"
},
"profile": {
+ "admins_and_creators": "Admins & Creators",
"new_password": "New password",
"no_pictures": "No pictures uploaded yet.",
"old_password": "Old password",
- "uploaded_pictures": "Uploaded pictures"
+ "own_role_hint": "You cannot change your own role.",
+ "promote": "Promote",
+ "promote_user": "Promote a user",
+ "role": "Role",
+ "role_changed": "Role updated!",
+ "role_change_error": "Could not change role!",
+ "role_confirm": "Do you want to change the role of {name} to {role}?",
+ "search_user": "Search user by email...",
+ "uploaded_pictures": "Uploaded pictures",
+ "user_management": "User management"
},
"quest": {
"already_finalized": "Answer is already finalized, no update allowed!",
diff --git a/app/pages/profile.vue b/app/pages/profile.vue
index 051168d..3428ff0 100644
--- a/app/pages/profile.vue
+++ b/app/pages/profile.vue
@@ -266,6 +266,7 @@ async function submitPassword() {
{{ t('page.profile.no_pictures') }}
+
diff --git a/server/api/admin/user/[userId]/role.post.ts b/server/api/admin/user/[userId]/role.post.ts
new file mode 100644
index 0000000..5eb32a2
--- /dev/null
+++ b/server/api/admin/user/[userId]/role.post.ts
@@ -0,0 +1,53 @@
+import { useValidatedBody, useValidatedParams, z, zh } from 'h3-zod';
+import prisma from '~~/lib/prisma';
+
+export default defineEventHandler(async (event) => {
+ const user = await requireUserSession(event);
+
+ if (user.user.role !== 'ADMIN') {
+ throw createError({
+ status: 403,
+ statusText: 'Not allowed!'
+ });
+ }
+
+ const { userId } = await useValidatedParams(
+ event,
+ z.object({
+ userId: zh.numAsString
+ })
+ );
+
+ const { role } = await useValidatedBody(
+ event,
+ z.object({
+ role: z.enum(['USER', 'CREATOR', 'ADMIN'])
+ })
+ );
+
+ if (userId === user.user.id) {
+ throw createError({
+ status: 400,
+ statusText: 'You cannot change your own role!'
+ });
+ }
+
+ const updated = await prisma.user.update({
+ where: {
+ id: userId,
+ deactivated: false,
+ deletedAt: null
+ },
+ data: {
+ role
+ },
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ role: true
+ }
+ });
+
+ return { success: true, user: updated };
+});
diff --git a/server/api/admin/user/index.get.ts b/server/api/admin/user/index.get.ts
new file mode 100644
index 0000000..07c6842
--- /dev/null
+++ b/server/api/admin/user/index.get.ts
@@ -0,0 +1,31 @@
+import prisma from '~~/lib/prisma';
+
+export default defineEventHandler(async (event) => {
+ const user = await requireUserSession(event);
+
+ if (user.user.role !== 'ADMIN') {
+ throw createError({
+ status: 403,
+ statusText: 'Not allowed!'
+ });
+ }
+
+ return prisma.user.findMany({
+ where: {
+ role: {
+ in: ['ADMIN', 'CREATOR']
+ },
+ deactivated: false,
+ deletedAt: null
+ },
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ role: true
+ },
+ orderBy: {
+ createdAt: 'asc'
+ }
+ });
+});
diff --git a/server/api/admin/user/search.get.ts b/server/api/admin/user/search.get.ts
new file mode 100644
index 0000000..63ae169
--- /dev/null
+++ b/server/api/admin/user/search.get.ts
@@ -0,0 +1,41 @@
+import { useValidatedQuery, z } from 'h3-zod';
+import prisma from '~~/lib/prisma';
+
+export default defineEventHandler(async (event) => {
+ const user = await requireUserSession(event);
+
+ if (user.user.role !== 'ADMIN') {
+ throw createError({
+ status: 403,
+ statusText: 'Not allowed!'
+ });
+ }
+
+ const { email } = await useValidatedQuery(
+ event,
+ z.object({
+ email: z.string().trim().default('')
+ })
+ );
+
+ return prisma.user.findMany({
+ where: {
+ role: 'USER',
+ deactivated: false,
+ deletedAt: null,
+ email: {
+ contains: email
+ }
+ },
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ role: true
+ },
+ take: 10,
+ orderBy: {
+ createdAt: 'desc'
+ }
+ });
+});