mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(auth): restore metadata editing lost to the 3.5.0 library roles
Promote Viewer grants to Editor on book libraries for non-superusers who hold Edit metadata but can edit nowhere, which is how every such account was left after the upgrade. Accounts with any Editor or Owner role are left alone. The Users drawer now says where Edit metadata takes effect, read from a new GET /users/:id/library-access route. Closes #1902
This commit is contained in:
@@ -14,11 +14,12 @@ import {
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from '@lucide/vue'
|
||||
import { Permission, type LibraryType } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import { formatDate, formatDateTime, formatNumber } from '@/i18n/formatters'
|
||||
import { formatDate, formatDateTime, formatList, formatNumber } from '@/i18n/formatters'
|
||||
import StatusPill from './components/StatusPill.vue'
|
||||
import UserPermissionsSection from './components/UserPermissionsSection.vue'
|
||||
import UserRestrictionsSection from './components/UserRestrictionsSection.vue'
|
||||
@@ -27,6 +28,7 @@ import { useUserForm, type UserFormSection, type UserFormTarget } from './compos
|
||||
interface LibraryOption {
|
||||
id: number
|
||||
name: string
|
||||
type?: LibraryType
|
||||
bookCount?: number
|
||||
}
|
||||
|
||||
@@ -57,6 +59,7 @@ const {
|
||||
isSharedAccount,
|
||||
selectedPermissionNames,
|
||||
selectedLibraryIds,
|
||||
editorLibraryIds,
|
||||
includeTagItems,
|
||||
excludeTagItems,
|
||||
includeGenreItems,
|
||||
@@ -183,6 +186,25 @@ const libraryReach = computed(() => {
|
||||
return { selected: formatNumber(selected), total: formatNumber(total) }
|
||||
})
|
||||
|
||||
/**
|
||||
* Edit metadata only works on book libraries where the account is Editor or Owner, which is set on
|
||||
* each library rather than here. Say so wherever that leaves the permission short of the selection.
|
||||
*/
|
||||
const permissionNotes = computed(() => {
|
||||
if (isSuperuserTarget.value || !selectedPermissionNames.value.has(Permission.LibraryEditMetadata)) return {}
|
||||
const bookLibraries = props.libraries.filter((library) => library.type !== 'podcasts' && selectedLibraryIds.value.has(library.id))
|
||||
const viewerOnly = bookLibraries.filter((library) => !editorLibraryIds.value.has(library.id))
|
||||
if (bookLibraries.length > 0 && viewerOnly.length === 0) return {}
|
||||
const note =
|
||||
viewerOnly.length === bookLibraries.length
|
||||
? { text: t('adminFeature.userForm.permissionNotes.editMetadataNowhere'), warning: true }
|
||||
: {
|
||||
text: t('adminFeature.userForm.permissionNotes.editMetadataExcept', { libraries: formatList(viewerOnly.map((library) => library.name)) }),
|
||||
warning: false,
|
||||
}
|
||||
return { [Permission.LibraryEditMetadata]: note }
|
||||
})
|
||||
|
||||
const errorMessage = computed(() => {
|
||||
if (error.value) return error.value
|
||||
if (!errorKey.value) return null
|
||||
@@ -540,6 +562,7 @@ async function handleSubmit() {
|
||||
:selected="selectedPermissionNames"
|
||||
:granted="grantedCount"
|
||||
:total="totalPermissions"
|
||||
:notes="permissionNotes"
|
||||
@toggle="togglePermission"
|
||||
@preset="applyPreset"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { Permission } from '@bookorbit/types'
|
||||
import { Permission, type UserLibraryGrant } from '@bookorbit/types'
|
||||
import UserFormDrawer from '../UserFormDrawer.vue'
|
||||
import { grantingPermissions } from '../lib/permission-presets'
|
||||
|
||||
@@ -20,9 +20,9 @@ vi.mock('@/components/ui/sheet', () => ({
|
||||
}))
|
||||
|
||||
const LIBRARIES = [
|
||||
{ id: 1, name: 'Novels', bookCount: 120 },
|
||||
{ id: 2, name: 'Comics', bookCount: 30 },
|
||||
{ id: 3, name: 'Podcasts', bookCount: 0 },
|
||||
{ id: 1, name: 'Novels', type: 'books' as const, bookCount: 120 },
|
||||
{ id: 2, name: 'Comics', type: 'books' as const, bookCount: 30 },
|
||||
{ id: 3, name: 'Podcasts', type: 'podcasts' as const, bookCount: 0 },
|
||||
]
|
||||
|
||||
const USER = {
|
||||
@@ -41,9 +41,9 @@ function jsonResponse(body: unknown) {
|
||||
return { ok: true, json: async () => body }
|
||||
}
|
||||
|
||||
function stubApi(overrides: { libraryIds?: number[]; filters?: unknown } = {}) {
|
||||
function stubApi(overrides: { grants?: UserLibraryGrant[]; filters?: unknown } = {}) {
|
||||
apiMock.mockImplementation(async (input: string) => {
|
||||
if (input.endsWith('/libraries')) return jsonResponse(overrides.libraryIds ?? [1])
|
||||
if (input.endsWith('/library-access')) return jsonResponse(overrides.grants ?? [{ libraryId: 1, accessLevel: 'viewer' }])
|
||||
if (input.endsWith('/content-filters')) {
|
||||
return jsonResponse(overrides.filters ?? { includeTags: [], excludeTags: [], includeGenres: [], excludeGenres: [] })
|
||||
}
|
||||
@@ -132,7 +132,7 @@ describe('UserFormDrawer sections', () => {
|
||||
})
|
||||
|
||||
it('warns when no library is selected at all', async () => {
|
||||
stubApi({ libraryIds: [] })
|
||||
stubApi({ grants: [] })
|
||||
const wrapper = await mountDrawer()
|
||||
await openSection(wrapper, 'Libraries')
|
||||
|
||||
@@ -203,6 +203,64 @@ describe('UserFormDrawer permissions', () => {
|
||||
expect(wrapper.text()).not.toContain('Upload books')
|
||||
})
|
||||
|
||||
describe('Edit metadata note', () => {
|
||||
const editor = { ...USER, permissions: [Permission.LibraryEditMetadata] }
|
||||
|
||||
function noteFor(wrapper: Drawer) {
|
||||
const id = checkboxFor(wrapper, 'Edit metadata')?.attributes('aria-describedby')
|
||||
return id ? wrapper.find(`[id="${id}"]`) : undefined
|
||||
}
|
||||
|
||||
it('warns that the permission does nothing while every book library is Viewer only', async () => {
|
||||
stubApi({
|
||||
grants: [
|
||||
{ libraryId: 1, accessLevel: 'viewer' },
|
||||
{ libraryId: 3, accessLevel: 'owner' },
|
||||
],
|
||||
})
|
||||
const wrapper = await mountDrawer({ user: editor })
|
||||
await openSection(wrapper, 'Permissions')
|
||||
|
||||
expect(noteFor(wrapper)?.text()).toContain('No effect yet')
|
||||
})
|
||||
|
||||
it('names the libraries where the account is still a Viewer', async () => {
|
||||
stubApi({
|
||||
grants: [
|
||||
{ libraryId: 1, accessLevel: 'editor' },
|
||||
{ libraryId: 2, accessLevel: 'viewer' },
|
||||
],
|
||||
})
|
||||
const wrapper = await mountDrawer({ user: editor })
|
||||
await openSection(wrapper, 'Permissions')
|
||||
|
||||
expect(noteFor(wrapper)?.text()).toContain('Not in Comics, where this account is a Viewer.')
|
||||
})
|
||||
|
||||
it('treats a library ticked in this form as Viewer, since that is how it saves', async () => {
|
||||
stubApi({ grants: [{ libraryId: 1, accessLevel: 'owner' }] })
|
||||
const wrapper = await mountDrawer({ user: editor })
|
||||
await openSection(wrapper, 'Permissions')
|
||||
expect(noteFor(wrapper)).toBeUndefined()
|
||||
|
||||
await openSection(wrapper, 'Libraries')
|
||||
await checkboxFor(wrapper, 'Comics')?.trigger('change')
|
||||
await openSection(wrapper, 'Permissions')
|
||||
|
||||
expect(noteFor(wrapper)?.text()).toContain('Not in Comics')
|
||||
})
|
||||
|
||||
it('stays quiet without the permission or for a superuser', async () => {
|
||||
const wrapper = await mountDrawer()
|
||||
await openSection(wrapper, 'Permissions')
|
||||
expect(wrapper.text()).not.toContain('No effect yet')
|
||||
|
||||
const superuser = await mountDrawer({ user: { ...editor, isSuperuser: true } })
|
||||
await openSection(superuser, 'Permissions')
|
||||
expect(superuser.text()).not.toContain('No effect yet')
|
||||
})
|
||||
})
|
||||
|
||||
it('says so when a filter matches nothing', async () => {
|
||||
const wrapper = await mountDrawer()
|
||||
await openSection(wrapper, 'Permissions')
|
||||
@@ -246,7 +304,7 @@ describe('UserFormDrawer saving', () => {
|
||||
if (input.endsWith('/permissions') && init?.method === 'PUT') {
|
||||
return { ok: false, json: async () => ({ message: 'Permission denied' }) }
|
||||
}
|
||||
if (input.endsWith('/libraries')) return jsonResponse([1])
|
||||
if (input.endsWith('/library-access')) return jsonResponse([{ libraryId: 1, accessLevel: 'viewer' }])
|
||||
if (input.endsWith('/content-filters')) return jsonResponse({})
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Search } from '@lucide/vue'
|
||||
import { Search, TriangleAlert } from '@lucide/vue'
|
||||
import { Permission, PERMISSION_LABELS } from '@bookorbit/types'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import {
|
||||
@@ -16,6 +16,8 @@ const props = defineProps<{
|
||||
selected: ReadonlySet<string>
|
||||
total: number
|
||||
granted: number
|
||||
/** Context a permission needs beyond its checkbox, such as where it actually takes effect. */
|
||||
notes?: Partial<Record<string, { text: string; warning: boolean }>>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -24,6 +26,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const noteIdPrefix = `permission-note-${useId()}`
|
||||
|
||||
const filter = ref('')
|
||||
const grantedOnly = ref(false)
|
||||
@@ -53,6 +56,7 @@ interface PermissionRow {
|
||||
label: string
|
||||
manage: boolean
|
||||
checked: boolean
|
||||
note?: { id: string; text: string; warning: boolean }
|
||||
}
|
||||
|
||||
interface RenderedGroup {
|
||||
@@ -77,12 +81,16 @@ const groups = computed<RenderedGroup[]>(() => {
|
||||
const permissions = permissionsInGroup(group)
|
||||
|
||||
const rows = permissions
|
||||
.map((permission) => ({
|
||||
name: permission,
|
||||
label: permissionLabel(permission),
|
||||
manage: group.manage.includes(permission),
|
||||
checked: props.selected.has(permission),
|
||||
}))
|
||||
.map((permission): PermissionRow => {
|
||||
const note = props.notes?.[permission]
|
||||
return {
|
||||
name: permission,
|
||||
label: permissionLabel(permission),
|
||||
manage: group.manage.includes(permission),
|
||||
checked: props.selected.has(permission),
|
||||
note: note && { ...note, id: `${noteIdPrefix}-${permission}` },
|
||||
}
|
||||
})
|
||||
.filter((row) => (grantedOnly.value ? row.checked : true))
|
||||
.filter((row) => (query.length === 0 || groupMatches ? true : row.label.toLowerCase().includes(query)))
|
||||
|
||||
@@ -209,25 +217,33 @@ function clearFilters() {
|
||||
</span>
|
||||
</div>
|
||||
<div :class="group.wide ? 'grid gap-x-4 @md:grid-cols-2 @xl:grid-cols-3' : ''">
|
||||
<label
|
||||
v-for="row in group.rows"
|
||||
:key="row.name"
|
||||
class="flex cursor-pointer items-start gap-2 rounded-xs py-1 focus-within:ring-2 focus-within:ring-ring"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="row.checked"
|
||||
class="mt-0.5 size-4 shrink-0 rounded-xs border-input accent-primary"
|
||||
@change="togglePermission(row.name)"
|
||||
/>
|
||||
<span class="text-sm leading-tight text-foreground">{{ row.label }}</span>
|
||||
<span
|
||||
v-if="row.manage && !group.manageOnly"
|
||||
class="ms-auto mt-0.5 shrink-0 rounded-xs bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
<div v-for="row in group.rows" :key="row.name">
|
||||
<label class="flex cursor-pointer items-start gap-2 rounded-xs py-1 focus-within:ring-2 focus-within:ring-ring">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="row.checked"
|
||||
:aria-describedby="row.note?.id"
|
||||
class="mt-0.5 size-4 shrink-0 rounded-xs border-input accent-primary"
|
||||
@change="togglePermission(row.name)"
|
||||
/>
|
||||
<span class="text-sm leading-tight text-foreground">{{ row.label }}</span>
|
||||
<span
|
||||
v-if="row.manage && !group.manageOnly"
|
||||
class="ms-auto mt-0.5 shrink-0 rounded-xs bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{{ t('adminFeature.userForm.permissionLevels.manage') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="row.note"
|
||||
:id="row.note.id"
|
||||
class="flex items-start gap-1.5 pb-1 ps-6 text-xs leading-snug"
|
||||
:class="row.note.warning ? 'text-[var(--pill-warning)]' : 'text-muted-foreground'"
|
||||
>
|
||||
{{ t('adminFeature.userForm.permissionLevels.manage') }}
|
||||
</span>
|
||||
</label>
|
||||
<TriangleAlert v-if="row.note.warning" :size="13" class="mt-px shrink-0" aria-hidden="true" />
|
||||
<span>{{ row.note.text }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import { PERMISSION_REQUIRES, type AuthUser, type Permission } from '@bookorbit/types'
|
||||
import { PERMISSION_REQUIRES, type AccessLevel, type AuthUser, type Permission, type UserLibraryGrant } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
import { grantingPermissions, isRestrictionPermission, presetPermissions, type PermissionPreset } from '../lib/permission-presets'
|
||||
|
||||
@@ -47,6 +47,8 @@ export function useUserForm(user: Ref<UserFormTarget | null>, defaultLibraryIds:
|
||||
const isSharedAccount = ref(false)
|
||||
const selectedPermissionNames = ref<Set<string>>(new Set())
|
||||
const selectedLibraryIds = ref<Set<number>>(new Set())
|
||||
/** Saved roles only: a library ticked in this form is granted as a Viewer when it saves. */
|
||||
const savedAccessLevels = ref<Map<number, AccessLevel>>(new Map())
|
||||
|
||||
const includeTagItems = ref<NamedItem[]>([])
|
||||
const excludeTagItems = ref<NamedItem[]>([])
|
||||
@@ -74,6 +76,17 @@ export function useUserForm(user: Ref<UserFormTarget | null>, defaultLibraryIds:
|
||||
excludeGenreItems.value.length > 0,
|
||||
)
|
||||
|
||||
/** Selected libraries where the account's role lets Edit metadata take effect. */
|
||||
const editorLibraryIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
[...selectedLibraryIds.value].filter((libraryId) => {
|
||||
const level = savedAccessLevels.value.get(libraryId)
|
||||
return level === 'editor' || level === 'owner'
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const restrictionPermissionNames = computed(() => [...selectedPermissionNames.value].filter(isRestrictionPermission))
|
||||
|
||||
const restrictionState = computed<RestrictionState>(() => {
|
||||
@@ -175,6 +188,7 @@ export function useUserForm(user: Ref<UserFormTarget | null>, defaultLibraryIds:
|
||||
isSharedAccount.value = target?.provisioningMethod === 'shared'
|
||||
selectedPermissionNames.value = new Set(target?.permissions?.filter((permissionName) => permissionName !== '*') ?? [])
|
||||
selectedLibraryIds.value = target?.id ? new Set() : new Set(defaultLibraryIds.value ?? [])
|
||||
savedAccessLevels.value = new Map()
|
||||
includeTagItems.value = []
|
||||
excludeTagItems.value = []
|
||||
includeGenreItems.value = []
|
||||
@@ -195,15 +209,16 @@ export function useUserForm(user: Ref<UserFormTarget | null>, defaultLibraryIds:
|
||||
}
|
||||
|
||||
const [librariesRes, filtersRes] = await Promise.all([
|
||||
api(`/api/v1/users/${target.id}/libraries`),
|
||||
api(`/api/v1/users/${target.id}/library-access`),
|
||||
target.isSuperuser ? Promise.resolve(null) : api(`/api/v1/users/${target.id}/content-filters`),
|
||||
])
|
||||
|
||||
if (user.value?.id !== target.id) return
|
||||
|
||||
if (librariesRes.ok) {
|
||||
const libraryIds: number[] = await librariesRes.json()
|
||||
selectedLibraryIds.value = new Set(libraryIds)
|
||||
const grants: UserLibraryGrant[] = await librariesRes.json()
|
||||
selectedLibraryIds.value = new Set(grants.map((grant) => grant.libraryId))
|
||||
savedAccessLevels.value = new Map(grants.map((grant) => [grant.libraryId, grant.accessLevel]))
|
||||
}
|
||||
if (filtersRes?.ok) {
|
||||
const filters = await filtersRes.json()
|
||||
@@ -344,6 +359,7 @@ export function useUserForm(user: Ref<UserFormTarget | null>, defaultLibraryIds:
|
||||
isSharedAccount,
|
||||
selectedPermissionNames,
|
||||
selectedLibraryIds,
|
||||
editorLibraryIds,
|
||||
includeTagItems,
|
||||
excludeTagItems,
|
||||
includeGenreItems,
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface UserRow extends AuthUser {
|
||||
provisioningMethod: ProvisioningMethod
|
||||
}
|
||||
|
||||
export type UserLibrary = Pick<Library, 'id' | 'name' | 'bookCount'>
|
||||
export type UserLibrary = Pick<Library, 'id' | 'name' | 'type' | 'bookCount'>
|
||||
|
||||
const EMPTY_SUMMARY: UserListSummary = { total: 0, admins: 0, active: 0, inactive: 0, attention: 0 }
|
||||
|
||||
|
||||
@@ -4889,6 +4889,10 @@
|
||||
"clearAll": "Clear",
|
||||
"permissionsHint": "What this account is allowed to do. {granted} of {total} granted.",
|
||||
"groupLegend": "{group}: {granted} of {total} granted",
|
||||
"permissionNotes": {
|
||||
"editMetadataNowhere": "No effect yet: it also needs the Editor or Owner role on a library, set in Settings > Libraries > Access.",
|
||||
"editMetadataExcept": "Not in {libraries}, where this account is a Viewer. Set Editor or Owner in Settings > Libraries > Access."
|
||||
},
|
||||
"presets": {
|
||||
"label": "Permission preset",
|
||||
"none": "None",
|
||||
|
||||
@@ -148,6 +148,11 @@ export interface LibraryAccessEntry {
|
||||
accessLevel: AccessLevel;
|
||||
}
|
||||
|
||||
export interface UserLibraryGrant {
|
||||
libraryId: number;
|
||||
accessLevel: AccessLevel;
|
||||
}
|
||||
|
||||
export interface DefaultLibraryAccessConfig {
|
||||
libraryIds: number[];
|
||||
}
|
||||
|
||||
@@ -621,6 +621,23 @@ export const E2E_SUITES = Object.freeze({
|
||||
...SHARED_DB_AND_HELPER_PATHS,
|
||||
],
|
||||
},
|
||||
"metadata-editor-access-migration": {
|
||||
id: "metadata-editor-access-migration",
|
||||
name: "Metadata Editor Access Migration",
|
||||
timeout: 60,
|
||||
lane: "full",
|
||||
description: "Editor role backfill for accounts that held Edit metadata before library roles were enforced",
|
||||
vitestTarget: "test/metadata-editor-access-migration.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/metadata-editor-access-migration-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
changedPaths: [
|
||||
"server/src/db/migrations/**",
|
||||
"server/src/scripts/migrate.ts",
|
||||
"server/test/metadata-editor-access-migration.e2e-spec.ts",
|
||||
...SHARED_DB_AND_HELPER_PATHS,
|
||||
],
|
||||
},
|
||||
"reading-attempts": {
|
||||
id: "reading-attempts",
|
||||
name: "Reading Attempts",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Since 3.5.0 metadata editing needs the Editor or Owner role on the book's library as well as the
|
||||
-- Edit metadata permission. Every existing grant was Viewer, so the upgrade silently took editing
|
||||
-- away from each non-superuser who held Edit metadata (issue 1902). Restore it on book libraries
|
||||
-- for the accounts the upgrade left unable to edit anywhere. An account that already holds Editor
|
||||
-- or Owner on some book library was set up under the new rules, so its Viewer grants are deliberate.
|
||||
UPDATE "user_library_access" AS "access"
|
||||
SET "access_level" = 'editor'
|
||||
FROM "libraries", "users"
|
||||
WHERE "libraries"."id" = "access"."library_id"
|
||||
AND "libraries"."type" = 'books'
|
||||
AND "users"."id" = "access"."user_id"
|
||||
AND "users"."is_superuser" = false
|
||||
AND "access"."access_level" = 'viewer'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "user_permissions"
|
||||
WHERE "user_permissions"."user_id" = "access"."user_id"
|
||||
AND "user_permissions"."permission_name" = 'library_edit_metadata'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "user_library_access" AS "editing"
|
||||
INNER JOIN "libraries" AS "editing_library" ON "editing_library"."id" = "editing"."library_id"
|
||||
WHERE "editing"."user_id" = "access"."user_id"
|
||||
AND "editing_library"."type" = 'books'
|
||||
AND "editing"."access_level" IN ('editor', 'owner')
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -918,6 +918,13 @@
|
||||
"when": 1792705454131,
|
||||
"tag": "0130_add_metadata_provider_icons",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 131,
|
||||
"version": "7",
|
||||
"when": 1792810767351,
|
||||
"tag": "0131_restore_metadata_editor_access",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ describe('UserController', () => {
|
||||
setPermissions: vi.fn(),
|
||||
setSuperuser: vi.fn(),
|
||||
getLibraryIds: vi.fn(),
|
||||
getLibraryGrants: vi.fn(),
|
||||
setLibraries: vi.fn(),
|
||||
adminResetPassword: vi.fn(),
|
||||
unlockUser: vi.fn(),
|
||||
@@ -174,6 +175,7 @@ describe('UserController', () => {
|
||||
await controller.setPermissions(8, dto as any, requester);
|
||||
await controller.setSuperuser(8, { isSuperuser: true }, requester);
|
||||
await controller.getLibraries(8);
|
||||
await controller.getLibraryGrants(8);
|
||||
await controller.setLibraries(8, setLibrariesDto as any, requester);
|
||||
await controller.adminResetPassword(8, requester);
|
||||
await controller.unlockUser(8, requester);
|
||||
@@ -185,6 +187,7 @@ describe('UserController', () => {
|
||||
expect(userService.setPermissions).toHaveBeenCalledWith(8, dto, requester);
|
||||
expect(userService.setSuperuser).toHaveBeenCalledWith(8, true, requester);
|
||||
expect(userService.getLibraryIds).toHaveBeenCalledWith(8);
|
||||
expect(userService.getLibraryGrants).toHaveBeenCalledWith(8);
|
||||
expect(userService.setLibraries).toHaveBeenCalledWith(8, [1, 2], requester);
|
||||
expect(userService.adminResetPassword).toHaveBeenCalledWith(8, requester);
|
||||
expect(userService.unlockUser).toHaveBeenCalledWith(8, requester);
|
||||
|
||||
@@ -277,6 +277,12 @@ export class UserController {
|
||||
return this.userService.getLibraryIds(id);
|
||||
}
|
||||
|
||||
@Get(':id/library-access')
|
||||
@RequirePermission(Permission.ManageUsers)
|
||||
getLibraryGrants(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.userService.getLibraryGrants(id);
|
||||
}
|
||||
|
||||
@Put(':id/libraries')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.ManageUsers)
|
||||
|
||||
@@ -851,6 +851,11 @@ describe('UserRepository', () => {
|
||||
|
||||
await expect(repo.findLibraryIdsByUserId(2)).resolves.toEqual([10, 11]);
|
||||
|
||||
where.mockResolvedValueOnce([{ libraryId: 10, accessLevel: 'editor' }]);
|
||||
await expect(repo.findLibraryGrantsByUserId(2)).resolves.toEqual([{ libraryId: 10, accessLevel: 'editor' }]);
|
||||
expect(select).toHaveBeenLastCalledWith({ libraryId: schema.userLibraryAccess.libraryId, accessLevel: schema.userLibraryAccess.accessLevel });
|
||||
expect(where).toHaveBeenLastCalledWith(expect.objectContaining({ op: 'eq', left: schema.userLibraryAccess.userId, right: 2 }));
|
||||
|
||||
const txDeleteWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const txDelete = vi.fn().mockReturnValue({ where: txDeleteWhere });
|
||||
const txOnConflictDoNothing = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -6,7 +6,14 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { hash } from 'bcryptjs';
|
||||
|
||||
import { Permission, withRequiredPermissions } from '@bookorbit/types';
|
||||
import type { UserAttentionReason, UserListSortDirection, UserListSortField, UserListState, UserListSummary } from '@bookorbit/types';
|
||||
import type {
|
||||
UserAttentionReason,
|
||||
UserLibraryGrant,
|
||||
UserListSortDirection,
|
||||
UserListSortField,
|
||||
UserListState,
|
||||
UserListSummary,
|
||||
} from '@bookorbit/types';
|
||||
import { RequestUser } from '../../common/types/request-user';
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
@@ -671,6 +678,13 @@ export class UserRepository {
|
||||
return rows.map((r) => r.libraryId);
|
||||
}
|
||||
|
||||
findLibraryGrantsByUserId(userId: number): Promise<UserLibraryGrant[]> {
|
||||
return this.db
|
||||
.select({ libraryId: schema.userLibraryAccess.libraryId, accessLevel: schema.userLibraryAccess.accessLevel })
|
||||
.from(schema.userLibraryAccess)
|
||||
.where(eq(schema.userLibraryAccess.userId, userId));
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -43,6 +43,7 @@ describe('UserService', () => {
|
||||
setSuperuser: vi.fn(),
|
||||
assignViewerLibraries: vi.fn(),
|
||||
findLibraryIdsByUserId: vi.fn(),
|
||||
findLibraryGrantsByUserId: vi.fn(),
|
||||
replaceLibraries: vi.fn(),
|
||||
findExistingLibraryIds: vi.fn(),
|
||||
findSettingsById: vi.fn(),
|
||||
@@ -639,6 +640,25 @@ describe('UserService', () => {
|
||||
await expect(service.getLibraryIds(4)).resolves.toEqual([11, 12]);
|
||||
});
|
||||
|
||||
it('getLibraryGrants throws when target user does not exist', async () => {
|
||||
userRepo.findByIdWithPermissions.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getLibraryGrants(4)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(userRepo.findLibraryGrantsByUserId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getLibraryGrants returns each library with its access level', async () => {
|
||||
const grants = [
|
||||
{ libraryId: 11, accessLevel: 'viewer' },
|
||||
{ libraryId: 12, accessLevel: 'editor' },
|
||||
];
|
||||
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 4, isSuperuser: false });
|
||||
userRepo.findLibraryGrantsByUserId.mockResolvedValue(grants);
|
||||
|
||||
await expect(service.getLibraryGrants(4)).resolves.toEqual(grants);
|
||||
expect(userRepo.findLibraryGrantsByUserId).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('setLibraries rejects unknown libraries', async () => {
|
||||
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false });
|
||||
userRepo.findExistingLibraryIds.mockResolvedValue([3]);
|
||||
@@ -831,6 +851,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
|
||||
setSuperuser: vi.fn(),
|
||||
assignViewerLibraries: vi.fn(),
|
||||
findLibraryIdsByUserId: vi.fn(),
|
||||
findLibraryGrantsByUserId: vi.fn(),
|
||||
replaceLibraries: vi.fn(),
|
||||
findExistingLibraryIds: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ConfigType } from '@nestjs/config';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import type { ProvisioningMethod, UserAttentionResponse, UserListSummary, UserSettings } from '@bookorbit/types';
|
||||
import type { ProvisioningMethod, UserAttentionResponse, UserLibraryGrant, UserListSummary, UserSettings } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
@@ -422,6 +422,12 @@ export class UserService {
|
||||
return this.userRepo.findLibraryIdsByUserId(userId);
|
||||
}
|
||||
|
||||
async getLibraryGrants(userId: number): Promise<UserLibraryGrant[]> {
|
||||
const user = await this.userRepo.findByIdWithPermissions(userId);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return this.userRepo.findLibraryGrantsByUserId(userId);
|
||||
}
|
||||
|
||||
async setLibraries(targetUserId: number, libraryIds: number[], requestingUser: RequestUser): Promise<void> {
|
||||
const target = await this.userRepo.findByIdWithPermissions(targetUserId);
|
||||
if (!target) throw new NotFoundException('User not found');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"totalRoutes": 711,
|
||||
"totalRoutes": 712,
|
||||
"byPermission": {
|
||||
"(none)": 298,
|
||||
"Permission.ManageMetadataConfig": 47,
|
||||
@@ -8,7 +8,7 @@
|
||||
"Permission.BookDockAccess": 17,
|
||||
"Permission.ManageAppSettings": 62,
|
||||
"Permission.ManageLibraries": 24,
|
||||
"Permission.ManageUsers": 13,
|
||||
"Permission.ManageUsers": 14,
|
||||
"Permission.ManageEmail": 8,
|
||||
"Permission.KoboSync": 13,
|
||||
"Permission.NotificationAccess": 6,
|
||||
|
||||
@@ -766,5 +766,17 @@
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/user/user.controller.ts",
|
||||
"className": "UserController",
|
||||
"methodName": "getLibraryGrants",
|
||||
"httpMethod": "GET",
|
||||
"path": "/users/:id/library-access",
|
||||
"permissions": ["Permission.ManageUsers"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { readFile, readdir } from 'fs/promises';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Client } from 'pg';
|
||||
|
||||
const MIGRATIONS_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '../src/db/migrations');
|
||||
const BACKFILL_MIGRATION = '0131_restore_metadata_editor_access.sql';
|
||||
|
||||
function escapeIdentifier(identifier: string): string {
|
||||
return `"${identifier.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
async function applyMigrations(client: Client, filenames: string[]): Promise<void> {
|
||||
for (const filename of filenames) {
|
||||
await client.query(await readFile(join(MIGRATIONS_DIRECTORY, filename), 'utf8'));
|
||||
}
|
||||
}
|
||||
|
||||
describe('Metadata editor access migration (e2e)', { timeout: 60_000 }, () => {
|
||||
it('restores Editor on book libraries only for accounts the role enforcement left unable to edit', async () => {
|
||||
const baseUrl = new URL(process.env.DATABASE_URL!);
|
||||
const databaseName = `editor_access_${randomUUID().replace(/-/g, '').slice(0, 12)}_e2e`;
|
||||
const targetUrl = new URL(baseUrl);
|
||||
targetUrl.pathname = `/${databaseName}`;
|
||||
const adminUrl = new URL(baseUrl);
|
||||
adminUrl.pathname = '/postgres';
|
||||
|
||||
const admin = new Client({ connectionString: adminUrl.toString() });
|
||||
let target: Client | undefined;
|
||||
await admin.connect();
|
||||
|
||||
try {
|
||||
await admin.query(`CREATE DATABASE ${escapeIdentifier(databaseName)}`);
|
||||
target = new Client({ connectionString: targetUrl.toString() });
|
||||
await target.connect();
|
||||
await target.query(`
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
||||
CREATE EXTENSION IF NOT EXISTS "unaccent";
|
||||
CREATE EXTENSION IF NOT EXISTS "vector";
|
||||
CREATE OR REPLACE FUNCTION public.bookorbit_unaccent(value text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
PARALLEL SAFE
|
||||
STRICT
|
||||
AS $function$ SELECT public.unaccent('public.unaccent', value) $function$;
|
||||
`);
|
||||
|
||||
const migrationFiles = (await readdir(MIGRATIONS_DIRECTORY)).filter((filename) => filename.endsWith('.sql')).sort();
|
||||
await applyMigrations(
|
||||
target,
|
||||
migrationFiles.filter((filename) => filename < BACKFILL_MIGRATION),
|
||||
);
|
||||
await target.query(`
|
||||
INSERT INTO libraries (id, type, name) VALUES (1, 'books', 'Fiction'), (2, 'books', 'Comics'), (3, 'podcasts', 'Shows');
|
||||
INSERT INTO users (id, username, name, password_hash, is_superuser, active) VALUES
|
||||
(1, 'upgraded', 'Upgraded', 'x', false, true),
|
||||
(2, 'configured', 'Configured', 'x', false, true),
|
||||
(3, 'reader', 'Reader', 'x', false, true),
|
||||
(4, 'superuser', 'Superuser', 'x', true, true),
|
||||
(5, 'podcast-owner', 'Podcast owner', 'x', false, true),
|
||||
(6, 'suspended', 'Suspended', 'x', false, false);
|
||||
INSERT INTO user_permissions (user_id, permission_name) VALUES
|
||||
(1, 'library_edit_metadata'), (1, 'library_download'),
|
||||
(2, 'library_edit_metadata'),
|
||||
(3, 'library_download'),
|
||||
(4, 'library_edit_metadata'),
|
||||
(5, 'library_edit_metadata'),
|
||||
(6, 'library_edit_metadata');
|
||||
INSERT INTO user_library_access (user_id, library_id, access_level) VALUES
|
||||
(1, 1, 'viewer'), (1, 2, 'viewer'), (1, 3, 'viewer'),
|
||||
(2, 1, 'editor'), (2, 2, 'viewer'),
|
||||
(3, 1, 'viewer'),
|
||||
(4, 1, 'viewer'),
|
||||
(5, 1, 'viewer'), (5, 3, 'owner'),
|
||||
(6, 1, 'viewer');
|
||||
`);
|
||||
|
||||
await applyMigrations(
|
||||
target,
|
||||
migrationFiles.filter((filename) => filename >= BACKFILL_MIGRATION),
|
||||
);
|
||||
|
||||
const grants = await target.query<{ username: string; library: string; accessLevel: string }>(`
|
||||
SELECT users.username, libraries.name AS library, user_library_access.access_level AS "accessLevel"
|
||||
FROM user_library_access
|
||||
INNER JOIN users ON users.id = user_library_access.user_id
|
||||
INNER JOIN libraries ON libraries.id = user_library_access.library_id
|
||||
ORDER BY users.id, libraries.id
|
||||
`);
|
||||
expect(grants.rows).toEqual([
|
||||
{ username: 'upgraded', library: 'Fiction', accessLevel: 'editor' },
|
||||
{ username: 'upgraded', library: 'Comics', accessLevel: 'editor' },
|
||||
{ username: 'upgraded', library: 'Shows', accessLevel: 'viewer' },
|
||||
{ username: 'configured', library: 'Fiction', accessLevel: 'editor' },
|
||||
{ username: 'configured', library: 'Comics', accessLevel: 'viewer' },
|
||||
{ username: 'reader', library: 'Fiction', accessLevel: 'viewer' },
|
||||
{ username: 'superuser', library: 'Fiction', accessLevel: 'viewer' },
|
||||
{ username: 'podcast-owner', library: 'Fiction', accessLevel: 'editor' },
|
||||
{ username: 'podcast-owner', library: 'Shows', accessLevel: 'owner' },
|
||||
{ username: 'suspended', library: 'Fiction', accessLevel: 'editor' },
|
||||
]);
|
||||
} finally {
|
||||
await target?.end();
|
||||
await admin.query(`DROP DATABASE IF EXISTS ${escapeIdentifier(databaseName)} WITH (FORCE)`);
|
||||
await admin.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user