feat(admin): add user role management with promotion feature

This commit is contained in:
2026-07-30 23:13:38 +02:00
parent 370ba3406f
commit aa4632cab8
7 changed files with 348 additions and 2 deletions
+190
View File
@@ -0,0 +1,190 @@
<script setup lang="ts">
import type { UserRole } from '#shared/generated/prisma/enums';
const { t } = useI18n();
const { user } = useUserSession();
const { confirm } = useModal();
const { init } = useToast();
type RoleUser = {
id: number;
name: string;
email: string;
role: UserRole;
};
const isAdmin = computed(() => user.value?.role === 'ADMIN');
const rolesToOptions = (roles: UserRole[]) =>
computed(() =>
roles.map((role) => ({
label: t(`enums.user_role.${role}`),
value: role
}))
);
const roleOptions = rolesToOptions(['USER', 'CREATOR', 'ADMIN']);
const promoteOptions = rolesToOptions(['CREATOR', 'ADMIN']);
const { data, refresh, pending } = await useFetch('/api/admin/user', {
immediate: isAdmin.value,
default: () => [] as RoleUser[]
});
const roleLoading = ref(false);
async function changeRole(target: RoleUser, role: UserRole) {
if (!role || target.role === role) {
return;
}
const ok = await confirm({
title: t('validation.confirm'),
message: t('page.profile.role_confirm', {
name: target.name,
role: t(`enums.user_role.${role}`)
}),
okText: t('auth.accept')
});
if (!ok) {
return;
}
roleLoading.value = true;
try {
await $fetch(`/api/admin/user/${target.id}/role`, {
method: 'POST',
body: { role }
});
init({ message: t('page.profile.role_changed'), color: 'success' });
} catch (e) {
init({
title: t('page.profile.role_change_error'),
message: t(parseError(e)),
color: 'danger'
});
}
await refresh();
roleLoading.value = false;
}
const search = ref('');
const safeSearch = ref('');
const promoteUser = ref<RoleUser>();
const promoteRole = ref<UserRole>('CREATOR');
watchDebounced(
search,
(newSearch) => {
if (newSearch) {
safeSearch.value = newSearch;
}
},
{ debounce: 500, maxWait: 1000 }
);
const { data: searchUsers, pending: searchPending } = await useFetch(
() => `/api/admin/user/search?email=${safeSearch.value}`,
{
immediate: false,
lazy: true,
default: () => [] as RoleUser[]
}
);
async function promote() {
if (!promoteUser.value) {
return;
}
roleLoading.value = true;
try {
await $fetch(`/api/admin/user/${promoteUser.value.id}/role`, {
method: 'POST',
body: { role: promoteRole.value }
});
init({ message: t('page.profile.role_changed'), color: 'success' });
} catch (e) {
init({
title: t('page.profile.role_change_error'),
message: t(parseError(e)),
color: 'danger'
});
}
promoteUser.value = undefined;
search.value = '';
safeSearch.value = '';
await refresh();
roleLoading.value = false;
}
</script>
<template>
<VaCard v-if="isAdmin" stripe stripe-color="warning">
<VaCardTitle>{{ t('page.profile.admins_and_creators') }}</VaCardTitle>
<VaCardContent>
<div class="flex flex-col gap-4">
<VaAlert color="info" dense>{{
t('page.profile.own_role_hint')
}}</VaAlert>
<div v-for="u in data" :key="u.id" class="grid grid-cols-2 gap-1">
<div class="flex flex-col gap-1">
<span class="truncate font-bold">{{ u.name }}</span>
<span class="text-secondary truncate text-sm">{{ u.email }}</span>
</div>
<VaButtonToggle
round
preset="secondary"
border-color="danger"
color="danger"
class="ml-auto"
:model-value="u.role"
:options="roleOptions"
:disabled="u.id === user?.id || roleLoading"
size="small"
@update:model-value="changeRole(u, $event as UserRole)"
/>
</div>
<VaAlert v-if="pending" color="info">{{
t('vuestic.loading')
}}</VaAlert>
</div>
</VaCardContent>
</VaCard>
<VaCard v-if="isAdmin" stripe stripe-color="warning">
<VaCardTitle>{{ t('page.profile.promote_user') }}</VaCardTitle>
<VaCardContent>
<div class="flex flex-col gap-4">
<VaSelect
v-model="promoteUser"
v-model:search="search"
:label="t('page.profile.search_user')"
autocomplete
highlight-matched-text
:options="searchUsers"
:loading="searchPending"
track-by="id"
text-by="email"
/>
<VaButtonToggle
round
preset="secondary"
border-color="danger"
color="danger"
:model-value="promoteRole"
:options="promoteOptions"
@update:model-value="promoteRole = $event as UserRole"
/>
<VaButton
color="success"
:disabled="!promoteUser || roleLoading"
:loading="roleLoading"
@click="promote"
>{{ t('page.profile.promote') }}</VaButton
>
</div>
</VaCardContent>
</VaCard>
</template>
+16 -1
View File
@@ -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!",
+16 -1
View File
@@ -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!",
+1
View File
@@ -266,6 +266,7 @@ async function submitPassword() {
</VaCardContent>
<VaCardContent v-else>{{ t('page.profile.no_pictures') }}</VaCardContent>
</VaCard>
<AdminUserRoles />
</div>
</template>