feat(users): add ManageEmail permission and gated book actions

- split email into EmailSend (read/send) and ManageEmail (SMTP config); fix providers tab to be read-only for EmailSend
- add library reassignment to admin user form (GET/PUT /users/:id/libraries)
- hide Download, Edit Metadata, Delete actions throughout UI based on user permissions
- redirect non-manage_libraries users away from Libraries settings
This commit is contained in:
neon
2026-03-23 00:36:11 -06:00
parent 7095bbc668
commit df337e1b7e
25 changed files with 324 additions and 108 deletions
+7 -5
View File
@@ -2,6 +2,7 @@
import { computed, ref, useSlots, watch } from 'vue'
import { Download, FolderMinus, FolderPlus, ImageDown, Mail, Pencil, RefreshCw, Trash2, X } from 'lucide-vue-next'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const ICON_SIZE = 17
@@ -35,6 +36,7 @@ const emit = defineEmits<{
exit: []
}>()
const { hasPermission } = usePermissions()
const confirmingDelete = ref(false)
const exportMenuOpen = ref(false)
const slots = useSlots()
@@ -92,7 +94,7 @@ watch(
<TooltipContent side="top">Send via email</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="hasPermission('library_download')">
<TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="exportMenuOpen = true">
<Download :size="ICON_SIZE" />
@@ -119,7 +121,7 @@ watch(
<TooltipContent side="top">Remove from collection</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="hasPermission('library_edit_metadata')">
<TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="emit('edit')">
<Pencil :size="ICON_SIZE" />
@@ -128,9 +130,9 @@ watch(
<TooltipContent side="top">Edit metadata</TooltipContent>
</Tooltip>
<div :class="DIVIDER" />
<div v-if="hasPermission('library_edit_metadata')" :class="DIVIDER" />
<Tooltip>
<Tooltip v-if="hasPermission('library_edit_metadata')">
<TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]" @click="emit('refresh-metadata')">
<RefreshCw :size="ICON_SIZE" />
@@ -139,7 +141,7 @@ watch(
<TooltipContent side="top">Refresh metadata</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="hasPermission('library_edit_metadata')">
<TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]" @click="emit('re-extract-cover')">
<ImageDown :size="ICON_SIZE" />
+24 -11
View File
@@ -23,13 +23,7 @@ const emit = defineEmits<{
const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [
{
label: 'Content',
permissions: [
Permission.LibraryView,
Permission.LibraryDownload,
Permission.LibraryUpload,
Permission.LibraryEditMetadata,
Permission.LibraryDeleteBooks,
],
permissions: [Permission.LibraryDownload, Permission.LibraryUpload, Permission.LibraryEditMetadata, Permission.LibraryDeleteBooks],
},
{
label: 'Devices & Access',
@@ -37,7 +31,7 @@ const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [
},
{
label: 'Email',
permissions: [Permission.EmailSend],
permissions: [Permission.EmailSend, Permission.ManageEmail],
},
{
label: 'Administration',
@@ -58,7 +52,7 @@ const isEdit = computed(() => !!props.user?.id)
watch(
() => props.user,
(u) => {
async (u) => {
name.value = u?.name ?? ''
username.value = u?.username ?? ''
email.value = u?.email ?? ''
@@ -66,6 +60,14 @@ watch(
selectedPermissionNames.value = new Set(u?.permissions?.filter((p) => p !== '*') ?? [])
selectedLibraryIds.value = new Set()
error.value = null
if (u?.id) {
const res = await api(`/api/v1/users/${u.id}/libraries`)
if (res.ok) {
const ids: number[] = await res.json()
selectedLibraryIds.value = new Set(ids)
}
}
},
{ immediate: true },
)
@@ -112,6 +114,17 @@ async function handleSubmit() {
error.value = err.message ?? 'Failed to update permissions'
return
}
const libRes = await api(`/api/v1/users/${props.user!.id}/libraries`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryIds: [...selectedLibraryIds.value] }),
})
if (!libRes.ok) {
const err = await libRes.json().catch(() => ({}))
error.value = err.message ?? 'Failed to update library access'
return
}
} else {
const res = await api('/api/v1/users', {
method: 'POST',
@@ -188,8 +201,8 @@ async function handleSubmit() {
<label for="active" class="settings-label">Active</label>
</div>
<!-- Library access (create only) -->
<div v-if="!isEdit && libraries.length > 0" class="space-y-2">
<!-- Library access -->
<div v-if="libraries.length > 0" class="space-y-2">
<p class="settings-label">Library access</p>
<div class="space-y-1.5">
<label v-for="lib in libraries" :key="lib.id" class="flex cursor-pointer items-center gap-2">
@@ -401,11 +401,14 @@ async function handleSetStatus(status: ReadStatus) {
</DropdownMenuSub>
<!-- Download submenu -->
<DropdownMenuItem v-if="openableFiles.length === 1 && primaryFile" @click="handleDownloadFile(primaryFile)">
<DropdownMenuItem
v-if="hasPermission('library_download') && openableFiles.length === 1 && primaryFile"
@click="handleDownloadFile(primaryFile)"
>
<Download class="size-4 mr-2" />
Download
</DropdownMenuItem>
<DropdownMenuSub v-else-if="openableFiles.length > 1">
<DropdownMenuSub v-else-if="hasPermission('library_download') && openableFiles.length > 1">
<DropdownMenuSubTrigger>
<Download class="size-4 mr-2" />
Download
@@ -425,8 +428,8 @@ async function handleSetStatus(status: ReadStatus) {
<ExternalLink class="size-4 mr-2" />
Book Details
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSeparator v-if="hasPermission('library_edit_metadata')" />
<DropdownMenuSub v-if="hasPermission('library_edit_metadata')">
<DropdownMenuSubTrigger>
<Pencil class="size-4 mr-2" />
Metadata
@@ -441,7 +444,7 @@ async function handleSetStatus(status: ReadStatus) {
<RefreshCw v-else class="size-4 mr-2" />
Refresh Metadata
</DropdownMenuItem>
<DropdownMenuItem v-if="hasPermission('library_edit_metadata')" :disabled="reExtractingCover" @click="reExtractCover()">
<DropdownMenuItem :disabled="reExtractingCover" @click="reExtractCover()">
<Loader2 v-if="reExtractingCover" class="size-4 mr-2 animate-spin" />
<Image v-else class="size-4 mr-2" />
Regenerate Cover
@@ -474,7 +477,11 @@ async function handleSetStatus(status: ReadStatus) {
Send via Email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="emit('action', 'delete')">
<DropdownMenuItem
v-if="hasPermission('library_delete_books')"
class="text-destructive focus:text-destructive"
@click="emit('action', 'delete')"
>
<Trash2 class="size-4 mr-2" />
Delete
</DropdownMenuItem>
@@ -233,11 +233,14 @@ function openAuthorBrowse() {
Book Details
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })">
<DropdownMenuItem
v-if="hasPermission('library_edit_metadata')"
@click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })"
>
<Pencil class="size-4 mr-2" />
Edit Metadata
</DropdownMenuItem>
<DropdownMenuItem :disabled="refreshing" @click="refreshWithFeedback(book.id)">
<DropdownMenuItem v-if="hasPermission('library_edit_metadata')" :disabled="refreshing" @click="refreshWithFeedback(book.id)">
<Loader2 v-if="refreshing" class="size-4 mr-2 animate-spin" />
<RefreshCw v-else class="size-4 mr-2" />
Refresh Metadata
@@ -250,8 +253,12 @@ function openAuthorBrowse() {
<Send class="size-4 mr-2" />
Send via Email
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="emit('action', 'delete')">
<DropdownMenuSeparator v-if="hasPermission('library_delete_books')" />
<DropdownMenuItem
v-if="hasPermission('library_delete_books')"
class="text-destructive focus:text-destructive"
@click="emit('action', 'delete')"
>
<Trash2 class="size-4 mr-2" />
Delete
</DropdownMenuItem>
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, FolderPlus, Pencil, Star, Trash2, X } from 'lucide-vue-next'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose, DialogTitle, DialogDescription } from 'reka-ui'
@@ -14,6 +15,7 @@ import { getFormatColor } from '../lib/format-colors'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{ bookId: number | null; open: boolean }>()
const { hasPermission } = usePermissions()
const emit = defineEmits<{
'update:open': [value: boolean]
action: [type: 'edit-metadata' | 'add-to-collection' | 'delete']
@@ -364,7 +366,7 @@ function openDetails() {
<ExternalLink class="size-4" />
Details
</button>
<Tooltip>
<Tooltip v-if="hasPermission('library_edit_metadata')">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center gap-1.5 h-9 px-3 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
@@ -386,7 +388,7 @@ function openDetails() {
</TooltipTrigger>
<TooltipContent>Add to collection</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="hasPermission('library_delete_books')">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center h-9 px-3 rounded-md text-sm text-destructive hover:bg-destructive/10 transition-colors"
@@ -2,18 +2,23 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { normalizeBookDetailTab, type BookDetailTab } from '@/features/book/lib/book-detail-tabs'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const props = defineProps<{ bookId: number }>()
const route = useRoute()
const router = useRouter()
const { hasPermission } = usePermissions()
const activeTab = computed(() => normalizeBookDetailTab(route.query.tab))
const tabs: { label: string; tab: BookDetailTab }[] = [
{ label: 'Details', tab: 'details' },
{ label: 'Edit Metadata', tab: 'edit' },
{ label: 'Files', tab: 'files' },
]
const tabs = computed<{ label: string; tab: BookDetailTab }[]>(() => {
const result: { label: string; tab: BookDetailTab }[] = [{ label: 'Details', tab: 'details' }]
if (hasPermission('library_edit_metadata')) {
result.push({ label: 'Edit Metadata', tab: 'edit' })
}
result.push({ label: 'Files', tab: 'files' })
return result
})
function navigate(tab: BookDetailTab) {
router.push({ name: 'book-detail', params: { bookId: props.bookId }, query: { tab } })
@@ -576,7 +576,7 @@ watch(
</button>
<div class="flex gap-2">
<div class="flex-1">
<div v-if="hasPermission('library_download')" class="flex-1">
<BookDownloadButton :files="book.files" :book-id="book.id" />
</div>
<Tooltip>
@@ -590,7 +590,7 @@ watch(
</TooltipTrigger>
<TooltipContent>Add to collection</TooltipContent>
</Tooltip>
<Popover :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
<Popover v-if="hasPermission('library_delete_books')" :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
<PopoverTrigger as-child>
<button
class="flex flex-1 items-center justify-center h-9 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
@@ -8,11 +8,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { api } from '@/lib/api'
import { useBookDownload } from '@/features/book/composables/useBookDownload'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const props = defineProps<{ book: BookDetail }>()
const router = useRouter()
const { downloadFile: downloadBookFile } = useBookDownload()
const { hasPermission } = usePermissions()
const AUDIO_FORMATS = new Set(['m4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac'])
@@ -272,7 +274,7 @@ async function toggleWriteLog() {
<Headphones class="size-3.5" />
Play
</button>
<Tooltip>
<Tooltip v-if="hasPermission('library_download')">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center h-7 w-7 rounded border border-input bg-background hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Permission } from '@projectx/types'
import ProvidersTab from './ProvidersTab.vue'
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
import RecipientsTab from './RecipientsTab.vue'
@@ -12,16 +13,43 @@ import { useEmailProviders } from '../composables/useEmailProviders'
import { useEmailRecipients } from '../composables/useEmailRecipients'
import { useEmailTemplates } from '../composables/useEmailTemplates'
import { useEmailGroups } from '../composables/useEmailGroups'
import { EMAIL_TAB_LABELS, EMAIL_TABS, normalizeEmailTab, type EmailTab as Tab } from '@/features/email/lib/email-tabs'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { EMAIL_TAB_LABELS, normalizeEmailTab, type EmailTab as Tab } from '@/features/email/lib/email-tabs'
const { fetchProviders } = useEmailProviders()
const { fetchRecipients } = useEmailRecipients()
const { fetchTemplates } = useEmailTemplates()
const { fetchGroups } = useEmailGroups()
const { hasPermission } = usePermissions()
const canManageEmail = computed(() => hasPermission(Permission.ManageEmail))
const canSendEmail = computed(() => hasPermission(Permission.EmailSend))
const tabs = computed<{ id: Tab; label: string }[]>(() => {
const result: { id: Tab; label: string }[] = []
if (canManageEmail.value || canSendEmail.value) result.push({ id: 'providers', label: EMAIL_TAB_LABELS['providers'] })
if (canSendEmail.value) {
result.push(
{ id: 'recipients', label: EMAIL_TAB_LABELS['recipients'] },
{ id: 'groups', label: EMAIL_TAB_LABELS['groups'] },
{ id: 'templates', label: EMAIL_TAB_LABELS['templates'] },
{ id: 'preferences', label: EMAIL_TAB_LABELS['preferences'] },
{ id: 'history', label: EMAIL_TAB_LABELS['history'] },
)
}
return result
})
const route = useRoute()
const router = useRouter()
const activeTab = ref<Tab>(normalizeEmailTab(route.query.tab))
function resolveTab(value: unknown): Tab {
const normalized = normalizeEmailTab(value)
if (tabs.value.some((t) => t.id === normalized)) return normalized
return tabs.value[0]?.id ?? 'recipients'
}
const activeTab = ref<Tab>(resolveTab(route.query.tab))
if (!route.query.tab) {
router.replace({ name: 'settings-email', query: { ...route.query, tab: activeTab.value } })
@@ -30,7 +58,7 @@ if (!route.query.tab) {
watch(
() => route.query.tab,
(value) => {
activeTab.value = normalizeEmailTab(value)
activeTab.value = resolveTab(value)
},
)
@@ -42,11 +70,12 @@ function selectTab(tab: Tab) {
const loading = ref(true)
const error = ref<string | null>(null)
const tabs: { id: Tab; label: string }[] = EMAIL_TABS.map((id) => ({ id, label: EMAIL_TAB_LABELS[id] }))
onMounted(async () => {
try {
await Promise.all([fetchProviders(), fetchRecipients(), fetchTemplates(), fetchGroups()])
const fetches: Promise<unknown>[] = []
if (canManageEmail.value || canSendEmail.value) fetches.push(fetchProviders())
if (canSendEmail.value) fetches.push(fetchRecipients(), fetchTemplates(), fetchGroups())
await Promise.all(fetches)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load'
} finally {
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import { toast } from 'vue-sonner'
import { Plus, Pencil, Trash2, Star, Share2, Wifi, Server } from 'lucide-vue-next'
import { Permission } from '@projectx/types'
import { useEmailProviders, type EmailProvider, type EmailProviderForm } from '../composables/useEmailProviders'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useAuth } from '@/features/auth/composables/useAuth'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const {
@@ -17,7 +19,9 @@ const {
setSystemProvider,
clearSystemProvider,
} = useEmailProviders()
const { isSuperuser } = usePermissions()
const { isSuperuser, hasPermission } = usePermissions()
const { user } = useAuth()
const canManageEmail = computed(() => hasPermission(Permission.ManageEmail))
const showForm = ref(false)
const editingId = ref<number | null>(null)
@@ -160,7 +164,7 @@ async function test(p: EmailProvider) {
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">SMTP Providers</p>
<button
v-if="!showForm"
v-if="canManageEmail && !showForm"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
@click="openCreate()"
>
@@ -170,7 +174,7 @@ async function test(p: EmailProvider) {
</div>
<!-- Add/Edit form -->
<div v-if="showForm" class="border border-border rounded-lg p-5 bg-card space-y-4">
<div v-if="canManageEmail && showForm" class="border border-border rounded-lg p-5 bg-card space-y-4">
<p class="text-sm font-semibold text-foreground">{{ editingId ? 'Edit Provider' : 'New Provider' }}</p>
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
@@ -275,7 +279,13 @@ async function test(p: EmailProvider) {
<!-- Empty state -->
<div v-if="providers.length === 0 && !showForm" class="border border-border rounded-lg px-5 py-8 bg-card text-center">
<p class="text-sm text-muted-foreground">No providers yet. Add an SMTP provider to start sending emails.</p>
<p class="text-sm text-muted-foreground">
{{
canManageEmail
? 'No providers yet. Add an SMTP provider to start sending emails.'
: 'No providers available. Ask an administrator to add one.'
}}
</p>
</div>
<!-- List -->
@@ -306,7 +316,7 @@ async function test(p: EmailProvider) {
</TooltipTrigger>
<TooltipContent>{{ p.isSystemProvider ? 'Remove as system mail provider' : 'Set as system mail provider' }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="canManageEmail">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-7 h-7 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@@ -318,7 +328,7 @@ async function test(p: EmailProvider) {
</TooltipTrigger>
<TooltipContent>Test connection</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="p.userId === user?.id">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-7 h-7 rounded transition-colors"
@@ -330,7 +340,7 @@ async function test(p: EmailProvider) {
</TooltipTrigger>
<TooltipContent>Set as default</TooltipContent>
</Tooltip>
<Tooltip v-if="!p.isShared || p.userId !== null">
<Tooltip v-if="canManageEmail && (!p.isShared || p.userId !== null)">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-7 h-7 rounded transition-colors"
@@ -342,7 +352,7 @@ async function test(p: EmailProvider) {
</TooltipTrigger>
<TooltipContent>Toggle sharing</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="canManageEmail">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-7 h-7 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@@ -353,7 +363,7 @@ async function test(p: EmailProvider) {
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip v-if="p.userId !== null">
<Tooltip v-if="canManageEmail && p.userId !== null">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center w-7 h-7 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
@@ -38,6 +38,9 @@ import {
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { ScrollMode } from '../composables/usePdfLayout'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const { hasPermission } = usePermissions()
const props = defineProps<{
currentPage: number
@@ -234,7 +237,7 @@ function currentZoomValue(): string {
<TooltipContent>{{ isFullscreen ? 'Exit Fullscreen' : 'Fullscreen' }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="hasPermission('library_download')">
<TooltipTrigger as-child>
<a :href="`/api/v1/books/files/${fileId}/serve?download=1`" class="viewer-btn flex items-center justify-center">
<Download :size="14" />
@@ -12,9 +12,16 @@ import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryFileSync } from '@/features/library/composables/useLibraryFileSync'
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const route = useRoute()
const router = useRouter()
const { hasPermission } = usePermissions()
if (!hasPermission('manage_libraries')) {
router.replace({ name: 'settings-appearance' })
}
const { libraries, fetchLibraries, refreshLibraries } = useLibraries()
const { subscribeLibrary, getProgress, isScanning, progressMap, getCoverRefreshProgress, isRefreshingCovers } = useScanProgress()
@@ -17,10 +17,12 @@ const sections = computed<Section[]>(() => {
const su = isSuperuser.value
const result: Section[] = [
{ label: 'Libraries', routeName: 'settings-libraries' },
{ label: 'Appearance', routeName: 'settings-appearance' },
{ label: 'Reader', routeName: 'settings-reader-general' },
]
if (su || perms.includes('manage_libraries')) {
result.unshift({ label: 'Libraries', routeName: 'settings-libraries' })
}
if (su || perms.includes('manage_metadata_config')) {
result.push({ label: 'Metadata', routeName: 'settings-admin-metadata' })
}
@@ -30,8 +32,10 @@ const sections = computed<Section[]>(() => {
if (su) {
result.push({ label: 'Audit Log', routeName: 'settings-admin-audit-log' })
}
if (su || perms.includes('manage_app_settings')) {
if (su || perms.includes('staging_access')) {
result.push({ label: 'Staging', routeName: 'settings-admin-staging' })
}
if (su || perms.includes('manage_app_settings')) {
result.push({ label: 'File Naming', routeName: 'settings-admin-file-naming' })
}
if (su || perms.includes('manage_users')) {
@@ -40,7 +44,7 @@ const sections = computed<Section[]>(() => {
if (su || perms.includes('manage_app_settings')) {
result.push({ label: 'OIDC / SSO', routeName: 'settings-admin-oidc' })
}
if (su || perms.includes('email_send')) {
if (su || perms.includes('email_send') || perms.includes('manage_email')) {
result.push({ label: 'Email', routeName: 'settings-email' })
}
if (su || perms.includes('opds_access')) {
+1 -1
View File
@@ -53,7 +53,7 @@ export const routes: RouteRecordRaw[] = [
path: '/settings',
component: () => import('@/views/SettingsView.vue'),
children: [
{ path: '', redirect: { name: 'settings-libraries' } },
{ path: '', redirect: { name: 'settings-appearance' } },
{
path: 'libraries',
name: 'settings-libraries',
+8 -1
View File
@@ -11,8 +11,10 @@ import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
import { usePageTitle } from '@/composables/usePageTitle'
import { normalizeBookDetailTab } from '@/features/book/lib/book-detail-tabs'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const route = useRoute()
const { hasPermission } = usePermissions()
const bookId = computed(() => Number(route.params.bookId))
const tab = computed(() => normalizeBookDetailTab(route.query.tab))
@@ -63,7 +65,12 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
<BookDetailLayout :book-id="bookId">
<template v-if="detail">
<DetailsTab v-if="tab === 'details'" :book="detail" />
<EditMetadataTab v-else-if="tab === 'edit'" :book="detail" @saved="onMetadataSaved" @cover-changed="onCoverChanged" />
<EditMetadataTab
v-else-if="tab === 'edit' && hasPermission('library_edit_metadata')"
:book="detail"
@saved="onMetadataSaved"
@cover-changed="onCoverChanged"
/>
<FilesTab v-else-if="tab === 'files'" :book="detail" />
</template>