mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(users): allow setting default library access for new users
Add configuration settings and backend support to define default library access permissions for newly created users. Newly registered users automatically receive these default library access permissions upon creation. Also align current users page controls and magic link options in the admin tab settings UI, adding styled callout cards for empty states. Closes #486
This commit is contained in:
@@ -23,6 +23,7 @@ interface NamedItem {
|
||||
const props = defineProps<{
|
||||
user: Partial<AuthUser> | null
|
||||
libraries: Library[]
|
||||
defaultLibraryIds?: number[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -130,7 +131,7 @@ watch(
|
||||
active.value = u?.active ?? true
|
||||
isSharedAccount.value = u?.provisioningMethod === 'shared'
|
||||
selectedPermissionNames.value = new Set(u?.permissions?.filter((p) => p !== '*') ?? [])
|
||||
selectedLibraryIds.value = new Set()
|
||||
selectedLibraryIds.value = u?.id ? new Set() : new Set(props.defaultLibraryIds ?? [])
|
||||
error.value = null
|
||||
includeTagItems.value = []
|
||||
excludeTagItems.value = []
|
||||
@@ -163,6 +164,13 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.defaultLibraryIds,
|
||||
(ids) => {
|
||||
if (!isEdit.value) selectedLibraryIds.value = new Set(ids ?? [])
|
||||
},
|
||||
)
|
||||
|
||||
watch(isMobile, (mobile) => {
|
||||
libraryAccessOpen.value = !mobile
|
||||
permissionGroupOpen.value = Object.fromEntries(PERMISSION_GROUPS.map((group) => [group.label, !mobile]))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { UserPlus, Plus, Pencil, KeyRound, Trash2, ShieldCheck, MoreVertical, ShieldAlert } from '@lucide/vue'
|
||||
import { UserPlus, Plus, Pencil, KeyRound, Trash2, ShieldCheck, MoreVertical, ShieldAlert, Save } from '@lucide/vue'
|
||||
import { api } from '@/lib/api'
|
||||
import type { AuthUser } from '@bookorbit/types'
|
||||
import { Permission, type AuthUser, type DefaultLibraryAccessConfig } from '@bookorbit/types'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import UserFormDrawer from './UserFormDrawer.vue'
|
||||
import ResetLinkModal from './ResetLinkModal.vue'
|
||||
@@ -28,14 +28,19 @@ interface UserRow extends AuthUser {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { isSuperuser } = usePermissions()
|
||||
const { isSuperuser, hasPermission } = usePermissions()
|
||||
|
||||
const users = ref<UserRow[]>([])
|
||||
const libraries = ref<Library[]>([])
|
||||
const defaultLibraryIds = ref<Set<number>>(new Set())
|
||||
const savedDefaultLibraryIds = ref<Set<number>>(new Set())
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const defaultLibraryAccessError = ref<string | null>(null)
|
||||
const defaultLibraryAccessSaved = ref(false)
|
||||
const savingDefaultLibraryAccess = ref(false)
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const editingUser = ref<Partial<AuthUser> | null>(null)
|
||||
@@ -44,6 +49,9 @@ const deleteConfirmUser = ref<UserRow | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const magicLinksRef = ref<InstanceType<typeof MagicLinksSettings> | null>(null)
|
||||
const canManageUserDefaults = computed(() => hasPermission(Permission.ManageUsers))
|
||||
const defaultLibraryIdsArray = computed(() => [...defaultLibraryIds.value])
|
||||
const hasDefaultLibraryChanges = computed(() => !setsEqual(defaultLibraryIds.value, savedDefaultLibraryIds.value))
|
||||
|
||||
function normalizeTab(v: unknown): Tab {
|
||||
if (v === 'magic-links' && isSuperuser.value) return 'magic-links'
|
||||
@@ -67,14 +75,23 @@ function selectTab(tab: Tab) {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
defaultLibraryAccessError.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 [usersRes, libsRes, defaultAccessRes] = await Promise.all([
|
||||
api(`/api/v1/users?page=${page.value}&pageSize=50`),
|
||||
api('/api/v1/libraries'),
|
||||
api('/api/v1/app-settings/default-library-access'),
|
||||
])
|
||||
if (!usersRes.ok || !libsRes.ok || !defaultAccessRes.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
|
||||
const defaultAccess = (await defaultAccessRes.json()) as DefaultLibraryAccessConfig
|
||||
setDefaultLibrarySelection(defaultAccess.libraryIds ?? [])
|
||||
savedDefaultLibraryIds.value = new Set(defaultAccess.libraryIds ?? [])
|
||||
defaultLibraryAccessSaved.value = false
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load'
|
||||
} finally {
|
||||
@@ -134,6 +151,56 @@ function onSaved(newResetUrl?: string) {
|
||||
function openMagicLinkCreate() {
|
||||
magicLinksRef.value?.openCreateForm()
|
||||
}
|
||||
|
||||
function setDefaultLibrarySelection(libraryIds: number[]) {
|
||||
defaultLibraryIds.value = new Set(libraryIds)
|
||||
}
|
||||
|
||||
function toggleDefaultLibrary(libraryId: number) {
|
||||
const next = new Set(defaultLibraryIds.value)
|
||||
if (next.has(libraryId)) {
|
||||
next.delete(libraryId)
|
||||
} else {
|
||||
next.add(libraryId)
|
||||
}
|
||||
defaultLibraryIds.value = next
|
||||
defaultLibraryAccessSaved.value = false
|
||||
}
|
||||
|
||||
async function saveDefaultLibraryAccess() {
|
||||
if (savingDefaultLibraryAccess.value) return
|
||||
savingDefaultLibraryAccess.value = true
|
||||
defaultLibraryAccessError.value = null
|
||||
defaultLibraryAccessSaved.value = false
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/default-library-access', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ libraryIds: defaultLibraryIdsArray.value }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
defaultLibraryAccessError.value = data.message ?? 'Failed to save default library access'
|
||||
return
|
||||
}
|
||||
const saved = (await res.json()) as DefaultLibraryAccessConfig
|
||||
setDefaultLibrarySelection(saved.libraryIds ?? [])
|
||||
savedDefaultLibraryIds.value = new Set(saved.libraryIds ?? [])
|
||||
defaultLibraryAccessSaved.value = true
|
||||
} catch (e) {
|
||||
defaultLibraryAccessError.value = e instanceof Error ? e.message : 'Failed to save default library access'
|
||||
} finally {
|
||||
savingDefaultLibraryAccess.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setsEqual(a: Set<number>, b: Set<number>) {
|
||||
if (a.size !== b.size) return false
|
||||
for (const value of a) {
|
||||
if (!b.has(value)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -193,14 +260,6 @@ function openMagicLinkCreate() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Embedded CTA -->
|
||||
<div v-if="props.embedded && activeTab === 'users'" class="mb-4 flex items-center justify-end md:mb-5">
|
||||
<button class="settings-btn-primary w-full justify-center md:w-auto" @click="openCreate">
|
||||
<UserPlus :size="14" />
|
||||
Create user
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile CTA (when no tab bar is shown, keep the sticky button) -->
|
||||
<div
|
||||
v-if="!isSuperuser && !props.embedded"
|
||||
@@ -217,174 +276,226 @@ function openMagicLinkCreate() {
|
||||
<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>
|
||||
<Tooltip v-if="user.hasContentFilters">
|
||||
<TooltipTrigger as-child>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
<ShieldAlert :size="11" />
|
||||
Filtered
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Content restrictions active</TooltipContent>
|
||||
</Tooltip>
|
||||
</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>
|
||||
<section v-else class="space-y-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="settings-group-label mb-0">Current Users</p>
|
||||
<button v-if="props.embedded" class="settings-btn-primary" @click="openCreate">
|
||||
<UserPlus :size="14" />
|
||||
Create user
|
||||
</button>
|
||||
</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 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>
|
||||
<Tooltip v-if="user.hasContentFilters">
|
||||
<TooltipTrigger as-child>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
<ShieldAlert :size="11" />
|
||||
Filtered
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Content restrictions active</TooltipContent>
|
||||
</Tooltip>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UserFormDrawer v-if="drawerOpen" :user="editingUser" :libraries="libraries" @close="drawerOpen = false" @saved="onSaved" />
|
||||
<section v-if="!loading && !error && canManageUserDefaults" class="mt-10 space-y-3">
|
||||
<div>
|
||||
<p class="settings-group-label mb-1">Default Library Access</p>
|
||||
<p class="text-sm text-muted-foreground">Viewer access granted to self-registered and OIDC-provisioned users.</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border bg-card p-4 shadow-xs">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<p class="text-sm text-muted-foreground">Select the libraries assigned automatically to new users.</p>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-btn-outline justify-center md:justify-start"
|
||||
:disabled="savingDefaultLibraryAccess || !hasDefaultLibraryChanges"
|
||||
@click="saveDefaultLibraryAccess"
|
||||
>
|
||||
<Save :size="14" />
|
||||
{{ savingDefaultLibraryAccess ? 'Saving...' : 'Save defaults' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="libraries.length > 0" class="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label v-for="lib in libraries" :key="lib.id" class="flex cursor-pointer items-center gap-2 rounded-md border border-border px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="defaultLibraryIds.has(lib.id)"
|
||||
class="h-4 w-4 rounded border-input"
|
||||
@change="toggleDefaultLibrary(lib.id)"
|
||||
/>
|
||||
<span class="min-w-0 truncate text-sm text-foreground">{{ lib.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-else class="mt-3 text-sm text-muted-foreground">No libraries available.</p>
|
||||
<p v-if="defaultLibraryAccessError" class="mt-3 text-sm text-destructive">{{ defaultLibraryAccessError }}</p>
|
||||
<p v-else-if="defaultLibraryAccessSaved" class="mt-3 text-sm text-muted-foreground">Default library access saved.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UserFormDrawer
|
||||
v-if="drawerOpen"
|
||||
:user="editingUser"
|
||||
:libraries="libraries"
|
||||
:default-library-ids="defaultLibraryIdsArray"
|
||||
@close="drawerOpen = false"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
<ResetLinkModal v-if="resetUrl" :reset-url="resetUrl" @close="resetUrl = null" />
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import UsersPage from '../UsersPage.vue'
|
||||
|
||||
const { apiMock, routeState, permState } = vi.hoisted(() => ({
|
||||
@@ -23,6 +23,7 @@ vi.mock('@/lib/api', () => ({
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
usePermissions: () => ({
|
||||
isSuperuser: computed(() => permState.isSuperuser),
|
||||
hasPermission: vi.fn<(name: string) => boolean>(() => true),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -38,13 +39,16 @@ describe('UsersPage', () => {
|
||||
if (input === '/api/v1/libraries') {
|
||||
return { ok: true, json: async () => ({ libraries: [] }) }
|
||||
}
|
||||
if (input === '/api/v1/app-settings/default-library-access') {
|
||||
return { ok: true, json: async () => ({ libraryIds: [] }) }
|
||||
}
|
||||
return { ok: false, json: async () => ({}) }
|
||||
})
|
||||
})
|
||||
|
||||
it('shows create user CTA when embedded on users tab', async () => {
|
||||
const wrapper = shallowMount(UsersPage, { props: { embedded: true } })
|
||||
await Promise.resolve()
|
||||
await flushPromises()
|
||||
|
||||
const createButton = wrapper.findAll('button').find((button) => button.text().includes('Create user'))
|
||||
expect(createButton).toBeDefined()
|
||||
|
||||
@@ -48,7 +48,7 @@ watch(availableTabs, (tabs) => {
|
||||
|
||||
const tabWidths: Record<Tab, string> = {
|
||||
users: 'max-w-6xl',
|
||||
'magic-links': 'max-w-6xl',
|
||||
'magic-links': 'max-w-5xl',
|
||||
oidc: 'max-w-3xl',
|
||||
}
|
||||
|
||||
|
||||
@@ -159,16 +159,6 @@ function isExpired(expiresAt: string | null | undefined): boolean {
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="props.withEmbeddedCreateAction" class="mb-4 flex items-center justify-end md:mb-5">
|
||||
<button class="settings-btn-primary w-full justify-center md:w-auto" :disabled="sharedUsers.length === 0" @click="openCreateForm">
|
||||
<Plus :size="14" />
|
||||
Create link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="sharedUsers.length === 0 && !loading" class="text-sm text-muted-foreground mb-4">
|
||||
No shared accounts found. Create a shared account from the Users page first.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
@@ -234,151 +224,189 @@ function isExpired(expiresAt: string | null | undefined): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active tokens -->
|
||||
<div v-if="!loading && activeTokens.length > 0" class="space-y-3">
|
||||
<h2 class="text-sm font-medium text-muted-foreground">Active links</h2>
|
||||
<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">Label</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Account</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Created by</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Expires</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Uses</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Last used</th>
|
||||
<th class="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr v-for="token in activeTokens" :key="token.id" class="hover:bg-muted/30 transition-colors" :class="!token.isActive ? 'opacity-60' : ''">
|
||||
<td class="px-4 py-3 text-foreground font-medium">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Link :size="12" class="text-muted-foreground shrink-0" />
|
||||
{{ token.label }}
|
||||
<span
|
||||
v-if="!token.isActive"
|
||||
class="inline-flex items-center rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground font-mono text-xs">@{{ token.username }}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground text-xs">{{ token.createdByUsername ?? 'Deleted user' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span v-if="token.expiresAt" class="text-xs" :class="isExpired(token.expiresAt) ? 'text-destructive' : 'text-muted-foreground'">
|
||||
{{ isExpired(token.expiresAt) ? 'Expired ' : '' }}{{ formatDate(token.expiresAt) }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-muted-foreground">Never</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground text-xs">{{ token.useCount }}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground text-xs">{{ formatDate(token.lastUsedAt) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="copiedId === token.id ? 'text-green-500' : 'text-muted-foreground hover:text-foreground hover:bg-muted'"
|
||||
@click="copyMagicUrl(token.id, token.rawToken)"
|
||||
>
|
||||
<Check v-if="copiedId === token.id" :size="14" />
|
||||
<Copy v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ copiedId === token.id ? 'Copied!' : 'Copy link' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleToggleActive(token.id, token.isActive)"
|
||||
>
|
||||
<Pause v-if="token.isActive" :size="14" />
|
||||
<Play v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ token.isActive ? 'Pause' : 'Resume' }}</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="revokeConfirmId = token.id"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Revoke</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Active links -->
|
||||
<div v-if="!loading && !error" class="space-y-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="settings-group-label mb-0">Active Links</p>
|
||||
<button v-if="props.withEmbeddedCreateAction" class="settings-btn-primary" :disabled="sharedUsers.length === 0" @click="openCreateForm">
|
||||
<Plus :size="14" />
|
||||
Create link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile active tokens -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<div
|
||||
v-for="token in activeTokens"
|
||||
:key="token.id"
|
||||
class="rounded-lg border border-border bg-card px-4 py-3.5 shadow-xs"
|
||||
:class="!token.isActive ? 'opacity-60' : ''"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Link :size="12" class="text-muted-foreground shrink-0" />
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ token.label }}</p>
|
||||
<span
|
||||
v-if="!token.isActive"
|
||||
class="inline-flex items-center rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">@{{ token.username }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="copiedId === token.id ? 'text-green-500' : 'text-muted-foreground hover:text-foreground hover:bg-muted'"
|
||||
@click="copyMagicUrl(token.id, token.rawToken)"
|
||||
>
|
||||
<Check v-if="copiedId === token.id" :size="14" />
|
||||
<Copy v-else :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleToggleActive(token.id, token.isActive)"
|
||||
>
|
||||
<Pause v-if="token.isActive" :size="14" />
|
||||
<Play v-else :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
@click="revokeConfirmId = token.id"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{{ token.useCount }} uses</span>
|
||||
<span v-if="token.expiresAt" :class="isExpired(token.expiresAt) ? 'text-destructive' : ''">
|
||||
{{ isExpired(token.expiresAt) ? 'Expired' : `Expires ${formatDate(token.expiresAt)}` }}
|
||||
</span>
|
||||
<span v-else>No expiry</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sharedUsers.length === 0 && tokens.length === 0" class="rounded-lg border border-border bg-muted/30 px-5 py-8 text-center">
|
||||
<Info :size="32" class="mx-auto mb-3 text-muted-foreground/60" />
|
||||
<p class="text-sm font-medium text-foreground">No shared accounts found</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Create a shared account from the
|
||||
<RouterLink to="/settings/admin?tab=users" class="text-primary hover:underline font-medium">Users page</RouterLink>
|
||||
first to generate magic links.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="activeTokens.length > 0">
|
||||
<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">Label</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Account</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Created by</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Expires</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Uses</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-muted-foreground">Last used</th>
|
||||
<th class="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr
|
||||
v-for="token in activeTokens"
|
||||
:key="token.id"
|
||||
class="hover:bg-muted/30 transition-colors"
|
||||
:class="!token.isActive ? 'opacity-60' : ''"
|
||||
>
|
||||
<td class="px-4 py-3 text-foreground font-medium">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Link :size="12" class="text-muted-foreground shrink-0" />
|
||||
{{ token.label }}
|
||||
<span
|
||||
v-if="!token.isActive"
|
||||
class="inline-flex items-center rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground font-mono text-xs">@{{ token.username }}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground text-xs">{{ token.createdByUsername ?? 'Deleted user' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span v-if="token.expiresAt" class="text-xs" :class="isExpired(token.expiresAt) ? 'text-destructive' : 'text-muted-foreground'">
|
||||
{{ isExpired(token.expiresAt) ? 'Expired ' : '' }}{{ formatDate(token.expiresAt) }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-muted-foreground">Never</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-muted-foreground text-xs">{{ token.useCount }}</td>
|
||||
<td class="px-4 py-3 text-muted-foreground text-xs">{{ formatDate(token.lastUsedAt) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="copiedId === token.id ? 'text-green-500' : 'text-muted-foreground hover:text-foreground hover:bg-muted'"
|
||||
@click="copyMagicUrl(token.id, token.rawToken)"
|
||||
>
|
||||
<Check v-if="copiedId === token.id" :size="14" />
|
||||
<Copy v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ copiedId === token.id ? 'Copied!' : 'Copy link' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleToggleActive(token.id, token.isActive)"
|
||||
>
|
||||
<Pause v-if="token.isActive" :size="14" />
|
||||
<Play v-else :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ token.isActive ? 'Pause' : 'Resume' }}</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="revokeConfirmId = token.id"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Revoke</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile active tokens -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<div
|
||||
v-for="token in activeTokens"
|
||||
:key="token.id"
|
||||
class="rounded-lg border border-border bg-card px-4 py-3.5 shadow-xs"
|
||||
:class="!token.isActive ? 'opacity-60' : ''"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Link :size="12" class="text-muted-foreground shrink-0" />
|
||||
<p class="text-sm font-medium text-foreground truncate">{{ token.label }}</p>
|
||||
<span
|
||||
v-if="!token.isActive"
|
||||
class="inline-flex items-center rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
Paused
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">@{{ token.username }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
class="p-1.5 rounded transition-colors"
|
||||
:class="copiedId === token.id ? 'text-green-500' : 'text-muted-foreground hover:text-foreground hover:bg-muted'"
|
||||
@click="copyMagicUrl(token.id, token.rawToken)"
|
||||
>
|
||||
<Check v-if="copiedId === token.id" :size="14" />
|
||||
<Copy v-else :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleToggleActive(token.id, token.isActive)"
|
||||
>
|
||||
<Pause v-if="token.isActive" :size="14" />
|
||||
<Play v-else :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
@click="revokeConfirmId = token.id"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>{{ token.useCount }} uses</span>
|
||||
<span v-if="token.expiresAt" :class="isExpired(token.expiresAt) ? 'text-destructive' : ''">
|
||||
{{ isExpired(token.expiresAt) ? 'Expired' : `Expires ${formatDate(token.expiresAt)}` }}
|
||||
</span>
|
||||
<span v-else>No expiry</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="tokens.length === 0" class="rounded-lg border border-border bg-muted/30 px-5 py-8 text-center">
|
||||
<Link :size="32" class="mx-auto mb-3 text-muted-foreground/60" />
|
||||
<p class="text-sm font-medium text-foreground">No magic links created yet</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Click "Create link" to generate a shareable login URL.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-lg border border-border bg-muted/30 px-5 py-6 text-center">
|
||||
<Link :size="24" class="mx-auto mb-2 text-muted-foreground/50" />
|
||||
<p class="text-sm font-medium text-foreground">No active magic links</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">All generated magic links for this page have expired or been revoked.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Inactive tokens (revoked or expired) -->
|
||||
<div v-if="!loading && inactiveTokens.length > 0" class="mt-6 space-y-3">
|
||||
<h2 class="text-sm font-medium text-muted-foreground">Inactive links</h2>
|
||||
<p class="settings-group-label">Inactive Links</p>
|
||||
<div class="hidden md:block rounded-lg border border-border overflow-hidden shadow-xs opacity-60">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-muted/50">
|
||||
@@ -415,10 +443,6 @@ function isExpired(expiresAt: string | null | undefined): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!loading && tokens.length === 0 && sharedUsers.length > 0" class="text-sm text-muted-foreground mt-4">
|
||||
No magic links created yet. Click "Create link" to generate a shareable login URL.
|
||||
</p>
|
||||
|
||||
<!-- Revoke confirmation modal -->
|
||||
<div
|
||||
v-if="revokeConfirmId !== null"
|
||||
|
||||
@@ -62,6 +62,7 @@ function mountComponent(props: { withHeader?: boolean; withEmbeddedCreateAction?
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div><slot /></div>' },
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ADMIN_TABS, ADMIN_TAB_INFO, normalizeAdminTab } from '../lib/admin-tabs
|
||||
|
||||
describe('admin-tabs', () => {
|
||||
describe('ADMIN_TABS', () => {
|
||||
it('contains exactly users, magic-links, and oidc', () => {
|
||||
expect(ADMIN_TABS).toEqual(['users', 'magic-links', 'oidc'])
|
||||
it('contains exactly users, oidc, and magic-links', () => {
|
||||
expect(ADMIN_TABS).toEqual(['users', 'oidc', 'magic-links'])
|
||||
})
|
||||
|
||||
it('has length 3', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const ADMIN_TABS = ['users', 'magic-links', 'oidc'] as const
|
||||
export const ADMIN_TABS = ['users', 'oidc', 'magic-links'] as const
|
||||
|
||||
export type AdminTab = (typeof ADMIN_TABS)[number]
|
||||
|
||||
|
||||
@@ -104,3 +104,7 @@ export interface LibraryAccessEntry {
|
||||
name: string;
|
||||
accessLevel: AccessLevel;
|
||||
}
|
||||
|
||||
export interface DefaultLibraryAccessConfig {
|
||||
libraryIds: number[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthorAutoEnrichmentConfig, OidcAutoProvision, OidcBaseConfig, OidcClaimMapping } from '@bookorbit/types';
|
||||
import type { AuthorAutoEnrichmentConfig, DefaultLibraryAccessConfig, OidcAutoProvision, OidcBaseConfig, OidcClaimMapping } from '@bookorbit/types';
|
||||
|
||||
export const APP_SETTING_KEYS = {
|
||||
ALLOW_REGISTRATION: 'allow_registration',
|
||||
@@ -15,6 +15,7 @@ export const APP_SETTING_KEYS = {
|
||||
AUTHORS_PROVIDER_AUDNEXUS_ENABLED: 'authors_provider_audnexus_enabled',
|
||||
AUTHORS_ENRICHMENT_PAUSED: 'authors_enrichment_paused',
|
||||
OIDC_CONFIG: 'oidc_config',
|
||||
DEFAULT_LIBRARY_ACCESS: 'default_library_access',
|
||||
UPLOAD_FILE_PATTERN: 'upload_file_pattern',
|
||||
UPLOAD_FILE_PATTERN_BOOK_PER_FOLDER: 'upload_file_pattern_book_per_folder',
|
||||
DOWNLOAD_FILE_PATTERN: 'download_file_pattern',
|
||||
@@ -55,6 +56,10 @@ export const DEFAULT_AUTO_PROVISION: OidcAutoProvision = {
|
||||
defaultPermissionNames: [],
|
||||
};
|
||||
|
||||
export const DEFAULT_LIBRARY_ACCESS_CONFIG: DefaultLibraryAccessConfig = {
|
||||
libraryIds: [],
|
||||
};
|
||||
|
||||
export const DEFAULT_AUTHOR_ENRICHMENT_CONFIG: AuthorAutoEnrichmentConfig = {
|
||||
enabled: false,
|
||||
triggerOnImport: true,
|
||||
|
||||
@@ -21,6 +21,8 @@ function makeService(): jest.Mocked<AppSettingsService> {
|
||||
setDownloadPattern: vi.fn(),
|
||||
isCrossPlatformPathSanitizationEnabled: vi.fn(),
|
||||
setCrossPlatformPathSanitizationEnabled: vi.fn(),
|
||||
getDefaultLibraryAccess: vi.fn(),
|
||||
setDefaultLibraryAccess: vi.fn(),
|
||||
getAutoFinalizeSettings: vi.fn(),
|
||||
getMetadataScoreWeights: vi.fn(),
|
||||
setMetadataScoreWeights: vi.fn(),
|
||||
@@ -138,6 +140,20 @@ describe('AppSettingsController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultLibraryAccess / setDefaultLibraryAccess', () => {
|
||||
it('getDefaultLibraryAccess returns configured IDs from service', async () => {
|
||||
service.getDefaultLibraryAccess.mockResolvedValue({ libraryIds: [1, 2] });
|
||||
expect(await controller.getDefaultLibraryAccess()).toEqual({ libraryIds: [1, 2] });
|
||||
});
|
||||
|
||||
it('setDefaultLibraryAccess delegates to service', async () => {
|
||||
service.setDefaultLibraryAccess.mockResolvedValue({ libraryIds: [3] });
|
||||
const result = await controller.setDefaultLibraryAccess({ libraryIds: [3] });
|
||||
expect(service.setDefaultLibraryAccess).toHaveBeenCalledWith({ libraryIds: [3] });
|
||||
expect(result).toEqual({ libraryIds: [3] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC provider CRUD', () => {
|
||||
it('getOidcProvidersPublic returns enabled providers without secrets', async () => {
|
||||
oidcProviderService.findEnabled.mockResolvedValue([
|
||||
|
||||
@@ -9,6 +9,7 @@ import { OidcProviderService } from './oidc-provider.service';
|
||||
import { UpdateAppSettingDto } from './dto/update-app-setting.dto';
|
||||
import { UpdateFilePatternDto } from './dto/update-file-pattern.dto';
|
||||
import { UpdateBooleanSettingDto } from './dto/update-boolean-setting.dto';
|
||||
import { UpdateDefaultLibraryAccessDto } from './dto/update-default-library-access.dto';
|
||||
import { CreateOidcProviderDto } from './dto/create-oidc-provider.dto';
|
||||
import { UpdateOidcProviderDto } from './dto/update-oidc-provider.dto';
|
||||
import { CreateGroupMappingDto } from './dto/create-group-mapping.dto';
|
||||
@@ -98,6 +99,24 @@ export class AppSettingsController {
|
||||
return { enabled: dto.enabled };
|
||||
}
|
||||
|
||||
@Get('default-library-access')
|
||||
@RequirePermission(Permission.ManageUsers)
|
||||
getDefaultLibraryAccess() {
|
||||
return this.appSettingsService.getDefaultLibraryAccess();
|
||||
}
|
||||
|
||||
@Put('default-library-access')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@RequirePermission(Permission.ManageUsers)
|
||||
@Auditable({
|
||||
action: AuditAction.AppSettingsUpdate,
|
||||
resource: AuditResource.AppSettings,
|
||||
description: 'Updated default library access',
|
||||
})
|
||||
setDefaultLibraryAccess(@Body() dto: UpdateDefaultLibraryAccessDto) {
|
||||
return this.appSettingsService.setDefaultLibraryAccess({ libraryIds: dto.libraryIds });
|
||||
}
|
||||
|
||||
// --- OIDC Provider CRUD ---
|
||||
|
||||
@Public()
|
||||
|
||||
@@ -80,6 +80,19 @@ describe('AppSettingsRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findExistingLibraryIds', () => {
|
||||
it('returns matching library IDs', async () => {
|
||||
db.where.mockResolvedValue([{ id: 2 }, { id: 4 }]);
|
||||
|
||||
await expect(repo.findExistingLibraryIds([2, 4])).resolves.toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it('returns empty array without querying when no IDs are provided', async () => {
|
||||
await expect(repo.findExistingLibraryIds([])).resolves.toEqual([]);
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateByKey', () => {
|
||||
it('returns updated setting when key exists', async () => {
|
||||
const setting = { key: 'allow_registration', value: 'false' };
|
||||
|
||||
@@ -24,6 +24,12 @@ export class AppSettingsRepository {
|
||||
return this.db.select().from(schema.appSettings).where(inArray(schema.appSettings.key, keys));
|
||||
}
|
||||
|
||||
async findExistingLibraryIds(libraryIds: number[]): Promise<number[]> {
|
||||
if (libraryIds.length === 0) return [];
|
||||
const rows = await this.db.select({ id: schema.libraries.id }).from(schema.libraries).where(inArray(schema.libraries.id, libraryIds));
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
|
||||
async updateByKey(key: string, value: string) {
|
||||
const [setting] = await this.db.update(schema.appSettings).set({ value }).where(eq(schema.appSettings.key, key)).returning();
|
||||
return setting ?? null;
|
||||
|
||||
@@ -17,6 +17,7 @@ function makeRepo(): jest.Mocked<AppSettingsRepository> {
|
||||
listPublic: vi.fn().mockResolvedValue([]),
|
||||
findByKey: vi.fn().mockResolvedValue(undefined),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
findExistingLibraryIds: vi.fn().mockResolvedValue([]),
|
||||
updateByKey: vi.fn().mockResolvedValue(null),
|
||||
upsert: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as jest.Mocked<AppSettingsRepository>;
|
||||
@@ -228,6 +229,37 @@ describe('AppSettingsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('default library access', () => {
|
||||
it('returns an empty list when no setting is stored', async () => {
|
||||
repo.findByKey.mockResolvedValue(undefined);
|
||||
await expect(service.getDefaultLibraryAccess()).resolves.toEqual({ libraryIds: [] });
|
||||
});
|
||||
|
||||
it('normalizes stored IDs and drops IDs for deleted libraries', async () => {
|
||||
repo.findByKey.mockResolvedValue({ key: 'default_library_access', value: JSON.stringify({ libraryIds: [3, 3, 5, -1, '7'] }) } as never);
|
||||
repo.findExistingLibraryIds.mockResolvedValue([5, 3]);
|
||||
|
||||
await expect(service.getDefaultLibraryAccess()).resolves.toEqual({ libraryIds: [3, 5] });
|
||||
expect(repo.findExistingLibraryIds).toHaveBeenCalledWith([3, 5]);
|
||||
});
|
||||
|
||||
it('stores validated default library IDs', async () => {
|
||||
repo.findExistingLibraryIds.mockResolvedValue([2, 4]);
|
||||
|
||||
const result = await service.setDefaultLibraryAccess({ libraryIds: [2, 4] });
|
||||
|
||||
expect(result).toEqual({ libraryIds: [2, 4] });
|
||||
expect(repo.upsert).toHaveBeenCalledWith('default_library_access', JSON.stringify({ libraryIds: [2, 4] }));
|
||||
});
|
||||
|
||||
it('rejects unknown default library IDs', async () => {
|
||||
repo.findExistingLibraryIds.mockResolvedValue([2]);
|
||||
|
||||
await expect(service.setDefaultLibraryAccess({ libraryIds: [2, 9] })).rejects.toThrow(BadRequestException);
|
||||
expect(repo.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('testOidcConnection', () => {
|
||||
const discoveryDoc = {
|
||||
issuer: 'https://kc.example.com/realms/main',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import {
|
||||
AuthorAutoEnrichmentWriteMode,
|
||||
type DefaultLibraryAccessConfig,
|
||||
DEFAULT_DOWNLOAD_PATTERN,
|
||||
DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE,
|
||||
DEFAULT_UPLOAD_PATTERN_BOOK_PER_FOLDER,
|
||||
@@ -11,7 +12,12 @@ import {
|
||||
type BookDockAutoFinalizeMetadataMode,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { APP_SETTING_KEYS, DEFAULT_OIDC_CONFIG, type OidcFullConfig } from '../../common/constants/app-settings.constants';
|
||||
import {
|
||||
APP_SETTING_KEYS,
|
||||
DEFAULT_LIBRARY_ACCESS_CONFIG,
|
||||
DEFAULT_OIDC_CONFIG,
|
||||
type OidcFullConfig,
|
||||
} from '../../common/constants/app-settings.constants';
|
||||
import { ensureSafeUrl } from '../../common/utils/ssrf.utils';
|
||||
import { AppSettingsRepository } from './app-settings.repository';
|
||||
|
||||
@@ -127,6 +133,36 @@ export class AppSettingsService {
|
||||
return merged;
|
||||
}
|
||||
|
||||
async getDefaultLibraryAccess(): Promise<DefaultLibraryAccessConfig> {
|
||||
const row = await this.repo.findByKey(APP_SETTING_KEYS.DEFAULT_LIBRARY_ACCESS);
|
||||
const stored = parseSafe<unknown>(APP_SETTING_KEYS.DEFAULT_LIBRARY_ACCESS, row?.value, DEFAULT_LIBRARY_ACCESS_CONFIG, this.logger);
|
||||
const normalized = normalizeDefaultLibraryAccess(stored);
|
||||
if (normalized.libraryIds.length === 0) return normalized;
|
||||
const existingIds = await this.repo.findExistingLibraryIds(normalized.libraryIds);
|
||||
return { libraryIds: normalized.libraryIds.filter((id) => existingIds.includes(id)) };
|
||||
}
|
||||
|
||||
async getDefaultLibraryAccessLibraryIds(): Promise<number[]> {
|
||||
return (await this.getDefaultLibraryAccess()).libraryIds;
|
||||
}
|
||||
|
||||
async setDefaultLibraryAccess(config: DefaultLibraryAccessConfig): Promise<DefaultLibraryAccessConfig> {
|
||||
const normalized = normalizeDefaultLibraryAccess(config);
|
||||
await this.assertKnownLibraryIds(normalized.libraryIds);
|
||||
await this.repo.upsert(APP_SETTING_KEYS.DEFAULT_LIBRARY_ACCESS, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async assertKnownLibraryIds(libraryIds: number[]): Promise<void> {
|
||||
if (libraryIds.length === 0) return;
|
||||
const existingIds = await this.repo.findExistingLibraryIds(libraryIds);
|
||||
const existingSet = new Set(existingIds);
|
||||
const missing = libraryIds.filter((id) => !existingSet.has(id));
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(`Unknown library IDs: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async testOidcConnection(issuerUri?: string): Promise<{
|
||||
success: boolean;
|
||||
issuer?: string;
|
||||
@@ -258,6 +294,17 @@ function mergeOidcConfig(base: OidcFullConfig, patch: Partial<OidcFullConfig>):
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDefaultLibraryAccess(value: unknown): DefaultLibraryAccessConfig {
|
||||
if (typeof value !== 'object' || value === null || !Array.isArray((value as { libraryIds?: unknown }).libraryIds)) {
|
||||
return { ...DEFAULT_LIBRARY_ACCESS_CONFIG };
|
||||
}
|
||||
|
||||
const libraryIds = (value as { libraryIds: unknown[] }).libraryIds.filter(
|
||||
(id): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
||||
);
|
||||
return { libraryIds: Array.from(new Set(libraryIds)) };
|
||||
}
|
||||
|
||||
function isOidcDiscoveryDoc(val: unknown): val is { issuer: string; authorization_endpoint: string } {
|
||||
return (
|
||||
typeof val === 'object' &&
|
||||
|
||||
@@ -5,6 +5,7 @@ import { validate } from 'class-validator';
|
||||
|
||||
import { UpdateAppSettingDto } from './update-app-setting.dto';
|
||||
import { UpdateBooleanSettingDto } from './update-boolean-setting.dto';
|
||||
import { UpdateDefaultLibraryAccessDto } from './update-default-library-access.dto';
|
||||
import { UpdateFilePatternDto } from './update-file-pattern.dto';
|
||||
import { UpdateOidcConfigDto } from './update-oidc-config.dto';
|
||||
|
||||
@@ -31,6 +32,13 @@ describe('App settings DTO validation', () => {
|
||||
expect((await errorsFor(UpdateBooleanSettingDto, { enabled: 'true' })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('validates default library access payload', async () => {
|
||||
expect((await errorsFor(UpdateDefaultLibraryAccessDto, { libraryIds: [1, 2] })).length).toBe(0);
|
||||
expect((await errorsFor(UpdateDefaultLibraryAccessDto, { libraryIds: [1, 1] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(UpdateDefaultLibraryAccessDto, { libraryIds: [0] })).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(UpdateDefaultLibraryAccessDto, { libraryIds: ['x'] })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('validates nested OIDC claimMapping and autoProvision fields', async () => {
|
||||
expect(
|
||||
(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ArrayUnique, IsArray, IsInt, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class UpdateDefaultLibraryAccessDto {
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
libraryIds: number[] = [];
|
||||
}
|
||||
@@ -91,6 +91,7 @@ function makeService(dbOverrides?: Record<string, unknown>) {
|
||||
};
|
||||
const appSettings = {
|
||||
getOidcConfig: vi.fn().mockResolvedValue({ enabled: false }),
|
||||
getDefaultLibraryAccessLibraryIds: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const oidcSessionRepo = {
|
||||
findActiveByUserId: vi.fn().mockResolvedValue(null),
|
||||
@@ -266,6 +267,21 @@ describe('AuthService', () => {
|
||||
const result = await service.register({ username: 'jdoe', name: 'John Doe', password: 'P@ssw0rd!', email: 'jdoe@example.com' } as never);
|
||||
expect(result).toEqual({ id: 1, username: 'jdoe', name: 'John Doe' });
|
||||
});
|
||||
|
||||
it('grants configured default library access to self-registered users', async () => {
|
||||
const { service, db, appSettings } = makeService();
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).appSettings.findFirst.mockResolvedValue({ value: 'true' });
|
||||
(db.query as never as Record<string, Record<string, vi.Mock>>).users.findFirst.mockResolvedValueOnce(null);
|
||||
appSettings.getDefaultLibraryAccessLibraryIds.mockResolvedValue([2, 4]);
|
||||
((db as unknown as Record<string, unknown>).returning as vi.Mock).mockResolvedValueOnce([{ id: 1, username: 'jdoe', name: 'John Doe' }]);
|
||||
|
||||
await service.register({ username: 'jdoe', name: 'John Doe', password: 'P@ssw0rd!', email: 'jdoe@example.com' } as never);
|
||||
|
||||
expect((db as unknown as { values: vi.Mock }).values).toHaveBeenCalledWith([
|
||||
{ userId: 1, libraryId: 2, accessLevel: 'viewer' },
|
||||
{ userId: 1, libraryId: 4, accessLevel: 'viewer' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
|
||||
@@ -92,6 +92,7 @@ export class AuthService {
|
||||
throw new ForbiddenException('Registration is not open');
|
||||
}
|
||||
|
||||
const defaultLibraryIds = await this.appSettings.getDefaultLibraryAccessLibraryIds();
|
||||
const passwordHash = await hash(dto.password, 12);
|
||||
return this.db.transaction(async (tx) => {
|
||||
const existingUsername = await tx.query.users.findFirst({
|
||||
@@ -117,6 +118,13 @@ export class AuthService {
|
||||
})
|
||||
.returning({ id: schema.users.id, username: schema.users.username, name: schema.users.name });
|
||||
|
||||
if (defaultLibraryIds.length > 0) {
|
||||
await tx
|
||||
.insert(schema.userLibraryAccess)
|
||||
.values(defaultLibraryIds.map((libraryId) => ({ userId: user.id, libraryId, accessLevel: 'viewer' as const })))
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
this.logger.log(`[auth.register] [end] userId=${user.id} username=${user.username} - registration completed`);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||
import { ContentFilterRepository } from './content-filter.repository';
|
||||
import { OidcIdentityRepository } from './oidc-identity.repository';
|
||||
import { UserAvatarStorageService } from './user-avatar-storage.service';
|
||||
@@ -9,6 +10,7 @@ import { UserRepository } from './user.repository';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppSettingsModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService, UserRepository, UserAvatarService, UserAvatarStorageService, OidcIdentityRepository, ContentFilterRepository],
|
||||
exports: [UserService, UserRepository, UserAvatarService, UserAvatarStorageService, OidcIdentityRepository, ContentFilterRepository],
|
||||
|
||||
@@ -49,18 +49,23 @@ describe('UserService', () => {
|
||||
};
|
||||
|
||||
const config = { get: vi.fn() };
|
||||
const appSettingsService = {
|
||||
getDefaultLibraryAccessLibraryIds: vi.fn(),
|
||||
};
|
||||
|
||||
let service: UserService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
service = new UserService(userRepo as any, config as any, contentFilterRepo as any);
|
||||
service = new UserService(userRepo as any, config as any, contentFilterRepo as any, appSettingsService as any);
|
||||
|
||||
mockHash.mockResolvedValue('hashed-secret');
|
||||
mockRandomBytes.mockReturnValue(Buffer.from('abcd', 'hex'));
|
||||
config.get.mockReturnValue('https://app.example.com');
|
||||
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
|
||||
|
||||
userRepo.create.mockResolvedValue({ id: 10, username: 'newuser', name: 'New User' });
|
||||
userRepo.createOidcUser.mockResolvedValue({ id: 11, username: 'oidcuser', name: 'OIDC User' });
|
||||
userRepo.findByEmail.mockResolvedValue(null);
|
||||
userRepo.generateResetToken.mockResolvedValue('reset-token');
|
||||
userRepo.findExistingLibraryIds.mockImplementation((ids: number[]) => Promise.resolve(ids));
|
||||
@@ -136,6 +141,29 @@ describe('UserService', () => {
|
||||
expect(result).toEqual({ id: 10, username: 'newuser', name: 'New User', resetUrl: 'https://app.example.com/reset-password?token=reset-token' });
|
||||
});
|
||||
|
||||
it('createUser applies default library access when libraries are omitted', async () => {
|
||||
userRepo.findByUsername.mockResolvedValue(null);
|
||||
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([2, 4]);
|
||||
|
||||
await service.createUser({
|
||||
username: 'newuser',
|
||||
name: 'New User',
|
||||
email: 'x@y.com',
|
||||
permissionNames: [],
|
||||
} as any);
|
||||
|
||||
expect(userRepo.assignViewerLibraries).toHaveBeenCalledWith(10, [2, 4]);
|
||||
});
|
||||
|
||||
it('createOidcUser applies default library access', async () => {
|
||||
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([6, 8]);
|
||||
|
||||
await service.createOidcUser({ username: 'oidc', name: 'OIDC', email: 'oidc@example.com', oidcSubject: 'sub', oidcIssuer: 'iss' });
|
||||
|
||||
expect(userRepo.createOidcUser).toHaveBeenCalled();
|
||||
expect(userRepo.assignViewerLibraries).toHaveBeenCalledWith(11, [6, 8]);
|
||||
});
|
||||
|
||||
it('createUser rejects unknown library IDs', async () => {
|
||||
userRepo.findByUsername.mockResolvedValue(null);
|
||||
userRepo.findExistingLibraryIds.mockResolvedValue([7]);
|
||||
@@ -562,11 +590,15 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
|
||||
replaceFilters: vi.fn(),
|
||||
};
|
||||
const config = { get: vi.fn() };
|
||||
const appSettingsService = {
|
||||
getDefaultLibraryAccessLibraryIds: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
config.get.mockReturnValue('http://localhost:5173');
|
||||
service = new UserService(userRepo as any, config as any, contentFilterRepo as any);
|
||||
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
|
||||
service = new UserService(userRepo as any, config as any, contentFilterRepo as any, appSettingsService as any);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when user does not exist', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UpdateMeSettingsDto } from './dto/update-me-settings.dto';
|
||||
import { UpdateSeriesCollapsePreferencesDto } from './dto/update-series-collapse-preferences.dto';
|
||||
import { UserRepository } from './user.repository';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
@@ -23,6 +24,7 @@ export class UserService {
|
||||
private readonly userRepo: UserRepository,
|
||||
private readonly config: ConfigService,
|
||||
private readonly contentFilterRepo: ContentFilterRepository,
|
||||
private readonly appSettingsService: AppSettingsService,
|
||||
) {}
|
||||
|
||||
findByUsername(username: string) {
|
||||
@@ -53,8 +55,10 @@ export class UserService {
|
||||
return this.userRepo.findPasswordHashById(userId);
|
||||
}
|
||||
|
||||
createOidcUser(data: Parameters<UserRepository['createOidcUser']>[0]) {
|
||||
return this.userRepo.createOidcUser(data);
|
||||
async createOidcUser(data: Parameters<UserRepository['createOidcUser']>[0]) {
|
||||
const user = await this.userRepo.createOidcUser(data);
|
||||
await this.assignConfiguredDefaultLibraries(user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
generatePasswordResetToken(userId: number): Promise<string> {
|
||||
@@ -107,7 +111,7 @@ export class UserService {
|
||||
await this.userRepo.setPermissions(user.id, permissionNames);
|
||||
}
|
||||
|
||||
const libraryIds = this.uniqueIds(dto.libraryIds ?? []);
|
||||
const libraryIds = await this.resolveNewUserLibraryIds(dto.libraryIds);
|
||||
if (libraryIds.length > 0) {
|
||||
await this.assertKnownLibraryIds(libraryIds);
|
||||
await this.userRepo.assignViewerLibraries(user.id, libraryIds);
|
||||
@@ -143,7 +147,7 @@ export class UserService {
|
||||
await this.userRepo.setPermissions(user.id, permissionNames);
|
||||
}
|
||||
|
||||
const libraryIds = this.uniqueIds(dto.libraryIds ?? []);
|
||||
const libraryIds = await this.resolveNewUserLibraryIds(dto.libraryIds);
|
||||
if (libraryIds.length > 0) {
|
||||
await this.assertKnownLibraryIds(libraryIds);
|
||||
await this.userRepo.assignViewerLibraries(user.id, libraryIds);
|
||||
@@ -300,6 +304,17 @@ export class UserService {
|
||||
return Array.from(new Set(ids));
|
||||
}
|
||||
|
||||
private async resolveNewUserLibraryIds(libraryIds: number[] | undefined): Promise<number[]> {
|
||||
if (libraryIds !== undefined) return this.uniqueIds(libraryIds);
|
||||
return this.appSettingsService.getDefaultLibraryAccessLibraryIds();
|
||||
}
|
||||
|
||||
private async assignConfiguredDefaultLibraries(userId: number): Promise<void> {
|
||||
const libraryIds = await this.appSettingsService.getDefaultLibraryAccessLibraryIds();
|
||||
if (libraryIds.length === 0) return;
|
||||
await this.userRepo.assignViewerLibraries(userId, libraryIds);
|
||||
}
|
||||
|
||||
private async assertEmailAvailable(email: string, targetUserId: number): Promise<void> {
|
||||
const existing = await this.userRepo.findByEmail(email);
|
||||
if (existing && existing.id !== targetUserId) {
|
||||
|
||||
Reference in New Issue
Block a user