214 lines
8.0 KiB
Vue
214 lines
8.0 KiB
Vue
<template>
|
|
<div class="flex h-full min-h-0 flex-col">
|
|
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-12">
|
|
<!-- Left: User List -->
|
|
<div class="panel flex min-h-[420px] flex-col p-4 md:col-span-4">
|
|
<div class="mb-3">
|
|
<IconField>
|
|
<InputIcon class="pi pi-search text-xs" />
|
|
<InputText
|
|
v-model.trim="userSearchTerm"
|
|
placeholder="Search users..."
|
|
class="search-input w-full text-sm"
|
|
@input="onUserSearch"
|
|
/>
|
|
</IconField>
|
|
</div>
|
|
|
|
<div v-if="loadingUsers" class="flex flex-1 items-center justify-center">
|
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
|
</div>
|
|
<div v-else-if="users.length === 0" class="muted-note p-4 text-center">
|
|
No users found
|
|
</div>
|
|
<div v-else class="min-h-0 flex-1 overflow-y-auto pr-1">
|
|
<div
|
|
v-for="u in users"
|
|
:key="u.id"
|
|
class="flex cursor-pointer items-center justify-between rounded-xl p-3 transition-colors mb-1"
|
|
:class="selectedUserId === u.id
|
|
? 'bg-indigo-500/10 text-indigo-500 dark:bg-indigo-500/20 font-semibold'
|
|
: 'hover:bg-slate-100 dark:hover:bg-slate-800/60'"
|
|
@click="selectUser(u.id)"
|
|
>
|
|
<div class="flex items-center gap-2.5">
|
|
<Avatar
|
|
:label="(u.displayName || u.username).slice(0, 2).toUpperCase()"
|
|
style="background: var(--primary); color: #fff"
|
|
shape="circle"
|
|
size="normal"
|
|
/>
|
|
<div>
|
|
<div class="text-sm leading-tight">{{ u.displayName }}</div>
|
|
<div class="muted-note text-xs">@{{ u.username }}</div>
|
|
</div>
|
|
</div>
|
|
<i v-if="selectedUserId === u.id" class="pi pi-chevron-right text-xs"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Top & Bottom: Roles (Unassigned / Assigned) -->
|
|
<div class="flex min-h-0 flex-col gap-4 md:col-span-8">
|
|
<div v-if="!selectedUserId" class="panel flex min-h-[420px] flex-1 items-center justify-center p-8 muted-note">
|
|
Select a user from the left list to manage roles
|
|
</div>
|
|
<template v-else>
|
|
<!-- Top Right: Unassigned Roles -->
|
|
<div class="panel flex min-h-[240px] flex-1 flex-col p-4" style="flex-basis: 0;">
|
|
<div class="eyebrow mb-3 flex items-center gap-2">
|
|
<i class="pi pi-plus-circle text-indigo-500 text-sm"></i> Unassigned Roles
|
|
</div>
|
|
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
|
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
|
</div>
|
|
<div v-else-if="unassignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
|
All available roles assigned
|
|
</div>
|
|
<div v-else class="min-h-0 flex-1 overflow-y-auto space-y-2 pr-1">
|
|
<div
|
|
v-for="r in unassignedRoles"
|
|
:key="r.id"
|
|
class="flex items-center justify-between rounded-xl border p-3"
|
|
style="border-color: var(--hairline)"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<span class="font-medium text-sm">{{ r.name }}</span>
|
|
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
|
</div>
|
|
<Button
|
|
label="Assign"
|
|
icon="pi pi-plus"
|
|
size="small"
|
|
:loading="assigningRoleId === r.id"
|
|
@click="handleAssignRole(r.id)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bottom Right: Assigned Roles -->
|
|
<div class="panel flex min-h-[240px] flex-1 flex-col p-4" style="flex-basis: 0;">
|
|
<div class="eyebrow mb-3 flex items-center gap-2">
|
|
<i class="pi pi-check-circle text-green-500 text-sm"></i> Assigned Roles
|
|
</div>
|
|
<div v-if="loadingUserRoles" class="flex flex-1 items-center justify-center">
|
|
<ProgressSpinner style="width: 32px; height: 32px;" />
|
|
</div>
|
|
<div v-else-if="assignedRoles.length === 0" class="flex flex-1 items-center justify-center muted-note">
|
|
No roles assigned yet
|
|
</div>
|
|
<div v-else class="min-h-0 flex-1 overflow-y-auto space-y-2 pr-1">
|
|
<div
|
|
v-for="r in assignedRoles"
|
|
:key="r.id"
|
|
class="flex items-center justify-between rounded-xl border p-3"
|
|
style="border-color: var(--hairline)"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<span class="font-medium text-sm">{{ r.name }}</span>
|
|
<Tag :value="r.isSystem ? 'System' : 'Custom'" :severity="r.isSystem ? 'warn' : 'secondary'" class="text-xs" />
|
|
</div>
|
|
<Button
|
|
label="Unassign"
|
|
icon="pi pi-times"
|
|
severity="danger"
|
|
text
|
|
size="small"
|
|
:loading="unassigningRoleId === r.id"
|
|
@click="handleUnassignRole(r.id)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { getUsers, getUserRoles, assignUserRole, unassignUserRole } from '../../../services/backend'
|
|
import { errorMessage } from '../../../services/api'
|
|
import type { User, Role } from '../../../types'
|
|
|
|
const toast = useToast()
|
|
|
|
const users = ref<User[]>([])
|
|
const loadingUsers = ref(false)
|
|
const userSearchTerm = ref('')
|
|
|
|
const selectedUserId = ref<string | null>(null)
|
|
const assignedRoles = ref<Role[]>([])
|
|
const unassignedRoles = ref<Role[]>([])
|
|
const loadingUserRoles = ref(false)
|
|
const assigningRoleId = ref<string | null>(null)
|
|
const unassigningRoleId = ref<string | null>(null)
|
|
|
|
let userSearchTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
async function fetchUsers(q?: string) {
|
|
loadingUsers.value = true
|
|
try {
|
|
users.value = await getUsers(q)
|
|
if (users.value.length > 0 && !selectedUserId.value) {
|
|
selectUser(users.value[0].id)
|
|
}
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
} finally {
|
|
loadingUsers.value = false
|
|
}
|
|
}
|
|
|
|
function onUserSearch() {
|
|
clearTimeout(userSearchTimer)
|
|
userSearchTimer = setTimeout(() => void fetchUsers(userSearchTerm.value), 300)
|
|
}
|
|
|
|
async function selectUser(userId: string) {
|
|
selectedUserId.value = userId
|
|
loadingUserRoles.value = true
|
|
try {
|
|
const data = await getUserRoles(userId)
|
|
assignedRoles.value = data.assignedRoles
|
|
unassignedRoles.value = data.unassignedRoles
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
} finally {
|
|
loadingUserRoles.value = false
|
|
}
|
|
}
|
|
|
|
async function handleAssignRole(roleId: string) {
|
|
if (!selectedUserId.value) return
|
|
assigningRoleId.value = roleId
|
|
try {
|
|
await assignUserRole(selectedUserId.value, roleId)
|
|
toast.add({ severity: 'success', summary: 'Role assigned', life: 2000 })
|
|
await selectUser(selectedUserId.value)
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
} finally {
|
|
assigningRoleId.value = null
|
|
}
|
|
}
|
|
|
|
async function handleUnassignRole(roleId: string) {
|
|
if (!selectedUserId.value) return
|
|
unassigningRoleId.value = roleId
|
|
try {
|
|
await unassignUserRole(selectedUserId.value, roleId)
|
|
toast.add({ severity: 'success', summary: 'Role unassigned', life: 2000 })
|
|
await selectUser(selectedUserId.value)
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
|
} finally {
|
|
unassigningRoleId.value = null
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void fetchUsers()
|
|
})
|
|
</script> |