forked from BRETT/BRETT
335 lines
11 KiB
Vue
335 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import type { ColumnDef } from '@tanstack/vue-table'
|
|
import { PERMISSIONS, SYSTEM_ROLES } from '~~/shared/utils/permissions'
|
|
import type { DataTableFeatures } from '@/lib/table'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
|
|
definePageMeta({ middleware: 'admin' })
|
|
|
|
interface Role {
|
|
id: number
|
|
name: string
|
|
memberCount: number
|
|
permissions: string[]
|
|
}
|
|
|
|
interface AdminUser {
|
|
id: number
|
|
name: string
|
|
email: string | null
|
|
roles: { id: number, name: string }[]
|
|
}
|
|
|
|
const { user, can } = useAuth()
|
|
const { data: rolesList, refresh: refreshRoles } = await useFetch<Role[]>('/api/admin/roles', { default: () => [] })
|
|
const { data: usersList, refresh: refreshUsers } = await useFetch<AdminUser[]>('/api/admin/users', { default: () => [] })
|
|
|
|
const newRoleName = ref('')
|
|
const motdDraft = ref('')
|
|
const motdSaving = ref(false)
|
|
const { data: motdData } = await useFetch<{ message: string }>('/api/motd', { default: () => ({ message: '' }) })
|
|
motdDraft.value = motdData.value.message
|
|
const errorMessage = ref('')
|
|
const selectedRole = ref<Record<number, number | undefined>>({})
|
|
const editedPermissions = ref<Record<number, string[]>>({})
|
|
const savingRole = ref<number | null>(null)
|
|
|
|
const userColumns: ColumnDef<DataTableFeatures, AdminUser>[] = [
|
|
{ accessorKey: 'name', header: 'Name' },
|
|
{ accessorKey: 'email', header: 'E-Mail', cell: ({ getValue }) => getValue() ?? '—' },
|
|
{ id: 'roles', header: 'Rollen', enableSorting: false, enableGlobalFilter: false },
|
|
{ id: 'actions', header: 'Aktion', enableSorting: false, enableGlobalFilter: false },
|
|
]
|
|
|
|
function userSearch(u: AdminUser, query: string) {
|
|
const q = query.toLowerCase()
|
|
return u.name.toLowerCase().includes(q) || (u.email ?? '').toLowerCase().includes(q)
|
|
}
|
|
|
|
function errText(err: unknown) {
|
|
const e = err as { data?: { statusMessage?: string, message?: string } }
|
|
return e.data?.statusMessage ?? e.data?.message ?? 'Aktion fehlgeschlagen'
|
|
}
|
|
|
|
async function refreshAll() {
|
|
await Promise.all([refreshRoles(), refreshUsers()])
|
|
}
|
|
|
|
async function createRole() {
|
|
if (!newRoleName.value.trim()) return
|
|
errorMessage.value = ''
|
|
try {
|
|
await $fetch('/api/admin/roles', { method: 'POST', body: { name: newRoleName.value.trim() } })
|
|
newRoleName.value = ''
|
|
await refreshRoles()
|
|
}
|
|
catch (err) {
|
|
errorMessage.value = errText(err)
|
|
}
|
|
}
|
|
|
|
async function deleteRole(roleId: number) {
|
|
errorMessage.value = ''
|
|
try {
|
|
await $fetch(`/api/admin/roles/${roleId}`, { method: 'DELETE' })
|
|
await refreshAll()
|
|
}
|
|
catch (err) {
|
|
errorMessage.value = errText(err)
|
|
}
|
|
}
|
|
|
|
async function addRole(userId: number) {
|
|
const roleId = selectedRole.value[userId]
|
|
if (!roleId) return
|
|
errorMessage.value = ''
|
|
try {
|
|
await $fetch(`/api/admin/users/${userId}/roles`, { method: 'POST', body: { roleId } })
|
|
selectedRole.value[userId] = undefined
|
|
await refreshAll()
|
|
}
|
|
catch (err) {
|
|
errorMessage.value = errText(err)
|
|
}
|
|
}
|
|
|
|
async function removeRole(userId: number, roleId: number) {
|
|
errorMessage.value = ''
|
|
try {
|
|
await $fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'DELETE' })
|
|
await refreshAll()
|
|
}
|
|
catch (err) {
|
|
errorMessage.value = errText(err)
|
|
}
|
|
}
|
|
|
|
function permissionsDraft(role: Role) {
|
|
return editedPermissions.value[role.id] ?? role.permissions
|
|
}
|
|
|
|
function isDirty(role: Role) {
|
|
const draft = editedPermissions.value[role.id]
|
|
return !!draft && (draft.length !== role.permissions.length || draft.some(k => !role.permissions.includes(k)))
|
|
}
|
|
|
|
function togglePermission(role: Role, key: string, checked: boolean | 'indeterminate') {
|
|
const current = permissionsDraft(role)
|
|
editedPermissions.value[role.id] = checked === true
|
|
? [...new Set([...current, key])]
|
|
: current.filter(k => k !== key)
|
|
}
|
|
|
|
async function savePermissions(role: Role) {
|
|
const draft = editedPermissions.value[role.id]
|
|
if (!draft) return
|
|
errorMessage.value = ''
|
|
savingRole.value = role.id
|
|
try {
|
|
await $fetch(`/api/admin/roles/${role.id}/permissions`, { method: 'PUT', body: { permissions: draft } })
|
|
delete editedPermissions.value[role.id]
|
|
await refreshRoles()
|
|
}
|
|
catch (err) {
|
|
errorMessage.value = errText(err)
|
|
}
|
|
finally {
|
|
savingRole.value = null
|
|
}
|
|
}
|
|
|
|
function resetPermissions(role: Role) {
|
|
delete editedPermissions.value[role.id]
|
|
}
|
|
|
|
async function saveMotd() {
|
|
motdSaving.value = true
|
|
errorMessage.value = ''
|
|
try {
|
|
const res = await $fetch<{ message: string }>('/api/admin/motd', {
|
|
method: 'PUT',
|
|
body: { message: motdDraft.value },
|
|
})
|
|
motdDraft.value = res.message
|
|
}
|
|
catch (err) {
|
|
errorMessage.value = errText(err)
|
|
}
|
|
finally {
|
|
motdSaving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="grid gap-6 px-4 lg:grid-cols-2 lg:px-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Rollen</CardTitle>
|
|
<CardDescription>Bestehende Rollen und neue anlegen (z. B. moderator). Die admin-Rolle hat immer alle Berechtigungen.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent class="space-y-4">
|
|
<ul class="space-y-2">
|
|
<li v-for="role in rolesList" :key="role.id" class="rounded-md border px-3 py-2">
|
|
<div class="flex items-center gap-3">
|
|
<span class="font-medium">{{ role.name }}</span>
|
|
<span class="text-sm text-muted-foreground">{{ role.memberCount }} Nutzer</span>
|
|
<Button
|
|
v-if="!SYSTEM_ROLES.includes(role.name as 'admin' | 'moderator') && can('roles.manage')"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
class="ml-auto text-muted-foreground"
|
|
@click="deleteRole(role.id)"
|
|
>
|
|
<Icon name="ph:trash-fill" class="size-4" />
|
|
<span class="sr-only">Rolle löschen</span>
|
|
</Button>
|
|
</div>
|
|
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
|
|
<label
|
|
v-for="perm in PERMISSIONS"
|
|
:key="perm.key"
|
|
class="flex items-center gap-1.5 text-sm"
|
|
>
|
|
<Checkbox
|
|
:model-value="role.name === 'admin' || permissionsDraft(role).includes(perm.key)"
|
|
:disabled="role.name === 'admin' || !can('roles.manage') || savingRole === role.id"
|
|
@update:model-value="togglePermission(role, perm.key, $event)"
|
|
/>
|
|
{{ perm.label }}
|
|
</label>
|
|
</div>
|
|
<div v-if="isDirty(role)" class="mt-2 flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
:disabled="savingRole === role.id"
|
|
@click="savePermissions(role)"
|
|
>
|
|
{{ savingRole === role.id ? 'Speichern…' : 'Speichern' }}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
:disabled="savingRole === role.id"
|
|
@click="resetPermissions(role)"
|
|
>
|
|
Zurücksetzen
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<form v-if="can('roles.manage')" class="flex gap-2" @submit.prevent="createRole">
|
|
<Input v-model="newRoleName" placeholder="Neuer Rollenname" maxlength="50" />
|
|
<Button type="submit" :disabled="!newRoleName.trim()">
|
|
Anlegen
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card v-if="can('users.manage')">
|
|
<CardHeader>
|
|
<CardTitle>Benutzer</CardTitle>
|
|
<CardDescription>Rollen zuweisen oder entfernen.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<DataTable
|
|
:columns="userColumns"
|
|
:data="usersList"
|
|
search-placeholder="Name oder E-Mail suchen…"
|
|
:search-fn="userSearch"
|
|
>
|
|
<template #cell-name="{ row }">
|
|
<span class="font-medium">{{ row.original.name }}</span>
|
|
</template>
|
|
<template #cell-roles="{ row }">
|
|
<div class="flex flex-wrap items-center gap-1.5">
|
|
<Badge v-for="r in row.original.roles" :key="r.id" variant="secondary" class="gap-1">
|
|
{{ r.name }}
|
|
<button
|
|
v-if="!(r.name === 'admin' && row.original.id === user?.id)"
|
|
type="button"
|
|
class="opacity-60 hover:opacity-100"
|
|
@click="removeRole(row.original.id, r.id)"
|
|
>
|
|
<Icon name="ph:x-fill" class="size-3" />
|
|
<span class="sr-only">Rolle {{ r.name }} entfernen</span>
|
|
</button>
|
|
</Badge>
|
|
<span v-if="!row.original.roles.length" class="text-xs text-muted-foreground">keine Rollen</span>
|
|
</div>
|
|
</template>
|
|
<template #cell-actions="{ row }">
|
|
<div class="flex items-center gap-2">
|
|
<Select
|
|
:model-value="selectedRole[row.original.id]"
|
|
:disabled="!rolesList.some(r => !row.original.roles.some(ur => ur.id === r.id))"
|
|
@update:model-value="selectedRole[row.original.id] = $event === undefined ? undefined : Number($event)"
|
|
>
|
|
<SelectTrigger size="sm" class="w-40" aria-label="Rolle wählen">
|
|
<SelectValue placeholder="Rolle wählen…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectGroup>
|
|
<SelectItem
|
|
v-for="role in rolesList.filter(r => !row.original.roles.some(ur => ur.id === r.id))"
|
|
:key="role.id"
|
|
:value="role.id"
|
|
>
|
|
{{ role.name }}
|
|
</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
:disabled="!selectedRole[row.original.id]"
|
|
@click="addRole(row.original.id)"
|
|
>
|
|
Zuweisen
|
|
</Button>
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card v-if="can('site.manage')" class="lg:col-span-2">
|
|
<CardHeader>
|
|
<CardTitle>Ansage</CardTitle>
|
|
<CardDescription>Wird allen Besuchern direkt unter der Kopfzeile angezeigt und bei Änderung sofort aktualisiert. Leer lassen, um sie zu entfernen.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form class="flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="saveMotd">
|
|
<Textarea
|
|
v-model="motdDraft"
|
|
placeholder="Ansagetext…"
|
|
maxlength="500"
|
|
class="min-h-16"
|
|
/>
|
|
<Button type="submit" class="shrink-0" :disabled="motdSaving">
|
|
{{ motdSaving ? 'Speichern…' : 'Speichern' }}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
<p v-if="errorMessage" class="text-sm text-destructive lg:col-span-2">
|
|
{{ errorMessage }}
|
|
</p>
|
|
</div>
|
|
</template>
|