mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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" />
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user