mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
395 lines
16 KiB
Vue
395 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { UserPlus, Plus, Pencil, KeyRound, Trash2, ShieldCheck, MoreVertical } from 'lucide-vue-next'
|
|
import { api } from '@/lib/api'
|
|
import type { AuthUser } from '@bookorbit/types'
|
|
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
|
import UserFormDrawer from './UserFormDrawer.vue'
|
|
import ResetLinkModal from './ResetLinkModal.vue'
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
|
import MagicLinksSettings from '@/features/settings/MagicLinksSettings.vue'
|
|
|
|
type Tab = 'users' | 'magic-links'
|
|
|
|
interface Library {
|
|
id: number
|
|
name: string
|
|
}
|
|
|
|
interface UserRow extends AuthUser {
|
|
id: number
|
|
}
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { isSuperuser } = usePermissions()
|
|
|
|
const users = ref<UserRow[]>([])
|
|
const libraries = ref<Library[]>([])
|
|
const total = ref(0)
|
|
const page = ref(0)
|
|
const loading = ref(false)
|
|
const error = ref<string | null>(null)
|
|
|
|
const drawerOpen = ref(false)
|
|
const editingUser = ref<Partial<AuthUser> | null>(null)
|
|
const resetUrl = ref<string | null>(null)
|
|
const deleteConfirmUser = ref<UserRow | null>(null)
|
|
const deleting = ref(false)
|
|
|
|
const magicLinksRef = ref<InstanceType<typeof MagicLinksSettings> | null>(null)
|
|
|
|
function normalizeTab(v: unknown): Tab {
|
|
if (v === 'magic-links' && isSuperuser.value) return 'magic-links'
|
|
return 'users'
|
|
}
|
|
|
|
const activeTab = ref<Tab>(normalizeTab(route.query.tab))
|
|
|
|
watch(
|
|
() => route.query.tab,
|
|
(v) => {
|
|
activeTab.value = normalizeTab(v)
|
|
},
|
|
)
|
|
|
|
function selectTab(tab: Tab) {
|
|
activeTab.value = tab
|
|
router.replace({ name: 'settings-admin-users', query: { tab } })
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
error.value = null
|
|
try {
|
|
const [usersRes, libsRes] = await Promise.all([api(`/api/v1/users?page=${page.value}&pageSize=50`), api('/api/v1/libraries')])
|
|
if (!usersRes.ok || !libsRes.ok) throw new Error('Failed to load data')
|
|
const ud = await usersRes.json()
|
|
users.value = ud.users ?? ud.items ?? ud
|
|
total.value = ud.total ?? users.value.length
|
|
const libData = await libsRes.json()
|
|
libraries.value = libData.libraries ?? libData.items ?? libData
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to load'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(loadData)
|
|
|
|
function openCreate() {
|
|
editingUser.value = null
|
|
drawerOpen.value = true
|
|
}
|
|
|
|
function openEdit(user: UserRow) {
|
|
editingUser.value = user
|
|
drawerOpen.value = true
|
|
}
|
|
|
|
async function handleResetPassword(userId: number) {
|
|
const res = await api(`/api/v1/users/${userId}/reset-password`, { method: 'POST' })
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
resetUrl.value = data.resetUrl
|
|
}
|
|
|
|
function requestDeleteUser(user: UserRow) {
|
|
deleteConfirmUser.value = user
|
|
}
|
|
|
|
async function confirmDeleteUser() {
|
|
if (!deleteConfirmUser.value || deleting.value) return
|
|
deleting.value = true
|
|
const user = deleteConfirmUser.value
|
|
try {
|
|
const res = await api(`/api/v1/users/${user.id}`, { method: 'DELETE' })
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
error.value = data.message ?? 'Failed to delete user'
|
|
return
|
|
}
|
|
deleteConfirmUser.value = null
|
|
loadData()
|
|
} catch {
|
|
error.value = 'Failed to delete user'
|
|
} finally {
|
|
deleting.value = false
|
|
}
|
|
}
|
|
|
|
function onSaved(newResetUrl?: string) {
|
|
drawerOpen.value = false
|
|
if (newResetUrl) resetUrl.value = newResetUrl
|
|
loadData()
|
|
}
|
|
|
|
function openMagicLinkCreate() {
|
|
magicLinksRef.value?.openCreateForm()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Desktop header -->
|
|
<SettingsPageHeader
|
|
class="hidden md:flex"
|
|
:title="activeTab === 'users' ? 'Users' : 'Magic Links'"
|
|
:subtitle="activeTab === 'users' ? 'Manage user accounts and permission assignments.' : 'Create shareable login links for shared accounts.'"
|
|
>
|
|
<button v-if="activeTab === 'users'" class="settings-btn-primary" @click="openCreate">
|
|
<UserPlus :size="14" />
|
|
Create user
|
|
</button>
|
|
<button v-else class="settings-btn-primary" @click="openMagicLinkCreate">
|
|
<Plus :size="14" />
|
|
Create link
|
|
</button>
|
|
</SettingsPageHeader>
|
|
|
|
<!-- Mobile title -->
|
|
<div class="md:hidden px-1">
|
|
<h1 class="text-xl font-semibold tracking-tight text-foreground">{{ activeTab === 'users' ? 'Users' : 'Magic Links' }}</h1>
|
|
<p
|
|
class="mt-1 text-sm text-muted-foreground leading-5 overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]"
|
|
>
|
|
{{ activeTab === 'users' ? 'Manage user accounts and permission assignments.' : 'Create shareable login links for shared accounts.' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Tab bar (superusers only) -->
|
|
<div
|
|
v-if="isSuperuser"
|
|
class="flex gap-1 mt-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
|
|
>
|
|
<button
|
|
class="px-3 py-3 md:py-2 text-sm font-medium shrink-0 border-b-2 -mb-px transition-colors snap-start"
|
|
:class="
|
|
activeTab === 'users'
|
|
? 'border-primary text-foreground'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
|
"
|
|
@click="selectTab('users')"
|
|
>
|
|
Users
|
|
</button>
|
|
<button
|
|
class="px-3 py-3 md:py-2 text-sm font-medium shrink-0 border-b-2 -mb-px transition-colors snap-start"
|
|
:class="
|
|
activeTab === 'magic-links'
|
|
? 'border-primary text-foreground'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
|
"
|
|
@click="selectTab('magic-links')"
|
|
>
|
|
Magic Links
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Mobile CTA (when no tab bar is shown, keep the sticky button) -->
|
|
<div v-if="!isSuperuser" class="md:hidden sticky top-0 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2 mt-4 mb-3">
|
|
<button class="settings-btn-primary w-full min-h-10 justify-center" @click="openCreate">
|
|
<UserPlus :size="14" />
|
|
Create user
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Users tab content -->
|
|
<template v-if="activeTab === 'users'">
|
|
<div v-if="error" class="mb-4 text-sm text-destructive">{{ error }}</div>
|
|
<div v-if="loading" class="text-sm text-muted-foreground">Loading...</div>
|
|
|
|
<div v-else class="space-y-3">
|
|
<div class="hidden md:block rounded-lg border border-border overflow-hidden shadow-xs">
|
|
<table class="w-full text-sm">
|
|
<thead class="bg-muted/50">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Username</th>
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground hidden sm:table-cell">Email</th>
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Access</th>
|
|
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
|
|
<th class="px-4 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-border">
|
|
<tr v-for="user in users" :key="user.id" class="hover:bg-muted/30 transition-colors">
|
|
<td class="px-4 py-3 text-foreground font-medium">{{ user.name }}</td>
|
|
<td class="px-4 py-3 text-muted-foreground font-mono text-xs">{{ user.username }}</td>
|
|
<td class="px-4 py-3 text-muted-foreground hidden sm:table-cell">{{ user.email ?? '-' }}</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center gap-1.5 flex-wrap">
|
|
<span
|
|
v-if="user.isSuperuser"
|
|
class="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary"
|
|
>
|
|
<ShieldCheck :size="11" />
|
|
Admin
|
|
</span>
|
|
<span v-else class="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
{{ user.permissions?.length ?? 0 }} permissions
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
|
:class="user.active ? 'bg-green-500/15 text-green-600 dark:text-green-400' : 'bg-destructive/15 text-destructive'"
|
|
>
|
|
{{ user.active ? 'Active' : 'Inactive' }}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex items-center gap-2 justify-end">
|
|
<template v-if="isSuperuser || !user.isSuperuser">
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
@click="openEdit(user)"
|
|
>
|
|
<Pencil :size="14" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Edit</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
:disabled="user.provisioningMethod === 'oidc' || user.provisioningMethod === 'shared'"
|
|
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
:class="
|
|
user.provisioningMethod === 'oidc' || user.provisioningMethod === 'shared'
|
|
? 'cursor-not-allowed opacity-50 hover:text-muted-foreground hover:bg-transparent'
|
|
: ''
|
|
"
|
|
@click="handleResetPassword(user.id)"
|
|
>
|
|
<KeyRound :size="14" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{{
|
|
user.provisioningMethod === 'oidc'
|
|
? 'OIDC users reset passwords in their identity provider'
|
|
: user.provisioningMethod === 'shared'
|
|
? 'Shared accounts do not have passwords'
|
|
: 'Reset password'
|
|
}}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger as-child>
|
|
<button
|
|
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
|
@click="requestDeleteUser(user)"
|
|
>
|
|
<Trash2 :size="14" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Delete</TooltipContent>
|
|
</Tooltip>
|
|
</template>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="md:hidden space-y-3">
|
|
<div v-for="user in users" :key="user.id" class="rounded-lg border border-border bg-card px-4 py-3.5 shadow-xs">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<p class="text-sm font-medium text-foreground leading-5">{{ user.name }}</p>
|
|
<span
|
|
class="shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
|
:class="user.active ? 'bg-green-500/15 text-green-600 dark:text-green-400' : 'bg-destructive/15 text-destructive'"
|
|
>
|
|
{{ user.active ? 'Active' : 'Inactive' }}
|
|
</span>
|
|
</div>
|
|
<div class="mt-2 flex items-center justify-between gap-3">
|
|
<p class="text-xs font-mono text-muted-foreground truncate">@{{ user.username }}</p>
|
|
<span
|
|
v-if="user.isSuperuser"
|
|
class="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-medium text-primary"
|
|
>
|
|
<ShieldCheck :size="11" />
|
|
Admin
|
|
</span>
|
|
<span v-else class="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
|
{{ user.permissions?.length ?? 0 }} permissions
|
|
</span>
|
|
</div>
|
|
<div v-if="isSuperuser || !user.isSuperuser" class="mt-3 flex items-center gap-2">
|
|
<button
|
|
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
|
@click="openEdit(user)"
|
|
>
|
|
Edit
|
|
</button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger as-child>
|
|
<button class="rounded-md border border-border p-2 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
|
|
<MoreVertical :size="16" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" class="w-56">
|
|
<DropdownMenuItem
|
|
:disabled="user.provisioningMethod === 'oidc' || user.provisioningMethod === 'shared'"
|
|
@click="handleResetPassword(user.id)"
|
|
>Reset password</DropdownMenuItem
|
|
>
|
|
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="requestDeleteUser(user)">Delete user</DropdownMenuItem>
|
|
<DropdownMenuSeparator v-if="user.provisioningMethod === 'oidc' || user.provisioningMethod === 'shared'" />
|
|
<p v-if="user.provisioningMethod === 'oidc'" class="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
OIDC users reset passwords in their identity provider.
|
|
</p>
|
|
<p v-else-if="user.provisioningMethod === 'shared'" class="px-2 py-1.5 text-[11px] leading-4 text-muted-foreground">
|
|
Shared accounts do not have passwords.
|
|
</p>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UserFormDrawer v-if="drawerOpen" :user="editingUser" :libraries="libraries" @close="drawerOpen = false" @saved="onSaved" />
|
|
<ResetLinkModal v-if="resetUrl" :reset-url="resetUrl" @close="resetUrl = null" />
|
|
|
|
<div
|
|
v-if="deleteConfirmUser"
|
|
class="fixed inset-0 z-[70] flex items-end justify-center md:items-center md:px-4"
|
|
@click.self="deleteConfirmUser = null"
|
|
>
|
|
<button class="absolute inset-0 bg-black/45" @click="deleteConfirmUser = null" />
|
|
<div class="relative w-full rounded-t-lg border border-border bg-card p-4 shadow-xl md:max-w-md md:rounded-lg md:p-5">
|
|
<p class="text-base font-semibold text-foreground">Delete user?</p>
|
|
<p class="mt-1 text-sm text-muted-foreground">Delete user "{{ deleteConfirmUser.username }}". This action cannot be undone.</p>
|
|
<div class="mt-4 flex items-center justify-end gap-2">
|
|
<button
|
|
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
|
@click="deleteConfirmUser = null"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
class="rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 transition-colors"
|
|
:disabled="deleting"
|
|
@click="confirmDeleteUser"
|
|
>
|
|
{{ deleting ? 'Deleting...' : 'Delete' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Magic Links tab content -->
|
|
<MagicLinksSettings v-else ref="magicLinksRef" :with-header="false" />
|
|
</template>
|