feat(admin): add user role management with promotion feature
This commit is contained in:
@@ -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
@@ -41,6 +41,11 @@
|
|||||||
"TEXT": "Freitext",
|
"TEXT": "Freitext",
|
||||||
"WORD": "Ein Wort",
|
"WORD": "Ein Wort",
|
||||||
"CHAR": "Ein Zeichen"
|
"CHAR": "Ein Zeichen"
|
||||||
|
},
|
||||||
|
"user_role": {
|
||||||
|
"ADMIN": "Admin",
|
||||||
|
"CREATOR": "Ersteller",
|
||||||
|
"USER": "Spieler"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -131,10 +136,20 @@
|
|||||||
"filter_quest": "Filtere Bilder nach Quests"
|
"filter_quest": "Filtere Bilder nach Quests"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
|
"admins_and_creators": "Admins & Ersteller",
|
||||||
"new_password": "Neues Passwort",
|
"new_password": "Neues Passwort",
|
||||||
"no_pictures": "Noch keine Bilder hochgeladen.",
|
"no_pictures": "Noch keine Bilder hochgeladen.",
|
||||||
"old_password": "Altes Passwort",
|
"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": {
|
"quest": {
|
||||||
"already_finalized": "Antwort ist bereits finalisiert, keine Änderung erlaubt!",
|
"already_finalized": "Antwort ist bereits finalisiert, keine Änderung erlaubt!",
|
||||||
|
|||||||
+16
-1
@@ -41,6 +41,11 @@
|
|||||||
"TEXT": "Free text",
|
"TEXT": "Free text",
|
||||||
"WORD": "One word",
|
"WORD": "One word",
|
||||||
"CHAR": "One character"
|
"CHAR": "One character"
|
||||||
|
},
|
||||||
|
"user_role": {
|
||||||
|
"ADMIN": "Admin",
|
||||||
|
"CREATOR": "Creator",
|
||||||
|
"USER": "Player"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -131,10 +136,20 @@
|
|||||||
"filter_quest": "Filter pictures by quests"
|
"filter_quest": "Filter pictures by quests"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
|
"admins_and_creators": "Admins & Creators",
|
||||||
"new_password": "New password",
|
"new_password": "New password",
|
||||||
"no_pictures": "No pictures uploaded yet.",
|
"no_pictures": "No pictures uploaded yet.",
|
||||||
"old_password": "Old password",
|
"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": {
|
"quest": {
|
||||||
"already_finalized": "Answer is already finalized, no update allowed!",
|
"already_finalized": "Answer is already finalized, no update allowed!",
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ async function submitPassword() {
|
|||||||
</VaCardContent>
|
</VaCardContent>
|
||||||
<VaCardContent v-else>{{ t('page.profile.no_pictures') }}</VaCardContent>
|
<VaCardContent v-else>{{ t('page.profile.no_pictures') }}</VaCardContent>
|
||||||
</VaCard>
|
</VaCard>
|
||||||
|
<AdminUserRoles />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user