feat(acl): tree-based access editor + permission-aware UI
Some checks failed
build-and-push / build (push) Failing after 1h0m8s
Some checks failed
build-and-push / build (push) Failing after 1h0m8s
Admin — Access Control is now a folder/file tree. Pick a user/group, click a node to set an allow and/or deny rule there; each node shows the subject's *effective* rights (letters S V R E C D M) and allow/deny "set here" badges, computed server-side incl. group membership, inherited folder rules and deny-wins. New queries: myAccess(paths) and subjectAccess(subjectType,subjectId,paths). Main UI — actions are hidden when the current (non-admin) user lacks the permission: create folder/document (Sidebar), drag-to-move, and Save/Delete in the document view. Adds a permission-gated Delete button to the document toolbar. Admins bypass and see everything. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,33 @@ import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { gql, createFolder, moveDocument } from '@/lib/gql'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { fetchMyAccess, type PathAccess } from '@/lib/access'
|
||||
import CreateItemDialog from './CreateItemDialog.vue'
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const app = useAppStore()
|
||||
|
||||
// ── Permission gating ──────────────────────────────────────────────────────
|
||||
const isAdmin = computed(() => app.role === 'admin')
|
||||
const perms = ref<Record<string, PathAccess>>({})
|
||||
|
||||
function canCreate(path: string) {
|
||||
return isAdmin.value || !!perms.value[path]?.canCreate
|
||||
}
|
||||
function canMove(slug: string) {
|
||||
return isAdmin.value || !!perms.value[slug]?.canMove
|
||||
}
|
||||
|
||||
async function refreshPerms() {
|
||||
if (isAdmin.value) return
|
||||
try {
|
||||
const paths = ['', ...allFolders.value, ...allDocs.value.map(d => d.slug)]
|
||||
perms.value = await fetchMyAccess(paths)
|
||||
} catch {
|
||||
perms.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
interface DocMeta { slug: string; title: string }
|
||||
|
||||
interface TreeFolder {
|
||||
@@ -101,6 +123,7 @@ async function handleDialogConfirm(name: string) {
|
||||
const fData = await gql<{ folders?: string[] }>(`{ folders }`)
|
||||
allFolders.value = fData.folders ?? []
|
||||
if (parentPath) openFolders[parentPath] = true
|
||||
await refreshPerms()
|
||||
} catch (err) {
|
||||
console.error('Failed to create folder:', err)
|
||||
}
|
||||
@@ -175,6 +198,7 @@ onMounted(async () => {
|
||||
openFolders[parts.slice(0, i).join('/')] = true
|
||||
}
|
||||
}
|
||||
await refreshPerms()
|
||||
} catch {
|
||||
allDocs.value = []
|
||||
allFolders.value = []
|
||||
@@ -309,6 +333,7 @@ function isActive(slug: string) {
|
||||
<span class="truncate font-medium">{{ item.name }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canCreate(item.path)"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity p-1 mr-1 rounded hover:bg-slate-200 dark:hover:bg-slate-600"
|
||||
@click.prevent.stop="showCreateMenu[item.path] = !showCreateMenu[item.path]"
|
||||
title="Add into folder"
|
||||
@@ -339,7 +364,7 @@ function isActive(slug: string) {
|
||||
<!-- Document row -->
|
||||
<button
|
||||
v-else
|
||||
draggable="true"
|
||||
:draggable="canMove((item as any).slug)"
|
||||
@dragstart="handleDragStart($event, item)"
|
||||
:style="{ paddingLeft: `${0.5 + item.depth * 1}rem` }"
|
||||
:class="[
|
||||
@@ -364,6 +389,7 @@ function isActive(slug: string) {
|
||||
<!-- Bottom actions -->
|
||||
<div class="p-3 border-t border-slate-200 dark:border-slate-700/60 flex flex-col gap-2">
|
||||
<button
|
||||
v-if="canCreate('')"
|
||||
class="w-full btn-secondary text-center text-sm py-1.5 flex items-center justify-center gap-2"
|
||||
@click="promptCreateRootFolder"
|
||||
>
|
||||
@@ -372,6 +398,7 @@ function isActive(slug: string) {
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canCreate('')"
|
||||
class="btn-primary w-full text-center text-sm py-2 flex items-center justify-center gap-2"
|
||||
@click="promptCreateItem('document', '')"
|
||||
>
|
||||
|
||||
35
frontend/src/lib/access.ts
Normal file
35
frontend/src/lib/access.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Helpers for permission-aware UI. The backend resolves the current user's
|
||||
// effective permissions (allow/deny, inherited, group-aware); the UI uses them
|
||||
// to hide actions the user cannot perform. Admins bypass ACLs entirely.
|
||||
|
||||
import { gql } from './gql'
|
||||
|
||||
export interface PathAccess {
|
||||
path: string
|
||||
canSearch: boolean
|
||||
canView: boolean
|
||||
canRead: boolean
|
||||
canEdit: boolean
|
||||
canCreate: boolean
|
||||
canDelete: boolean
|
||||
canMove: boolean
|
||||
}
|
||||
|
||||
export type Perm =
|
||||
| 'canSearch' | 'canView' | 'canRead'
|
||||
| 'canEdit' | 'canCreate' | 'canDelete' | 'canMove'
|
||||
|
||||
/** Fetch the current user's effective permissions for the given paths. */
|
||||
export async function fetchMyAccess(paths: string[]): Promise<Record<string, PathAccess>> {
|
||||
const uniq = [...new Set(paths)]
|
||||
const map: Record<string, PathAccess> = {}
|
||||
if (uniq.length === 0) return map
|
||||
const data = await gql<{ myAccess: PathAccess[] }>(
|
||||
`query MyAccess($paths: [String!]!) {
|
||||
myAccess(paths: $paths) { path canSearch canView canRead canEdit canCreate canDelete canMove }
|
||||
}`,
|
||||
{ paths: uniq },
|
||||
)
|
||||
for (const a of data.myAccess) map[a.path] = a
|
||||
return map
|
||||
}
|
||||
@@ -217,73 +217,182 @@ async function toggleMembership(username: string, group: string, member: boolean
|
||||
await gql(`mutation RUG($username:String!,$group:String!){ removeUserFromGroup(username:$username, group:$group) }`, { username, group })
|
||||
userGroupMap.value[username] = (userGroupMap.value[username] || []).filter(g => g !== group)
|
||||
}
|
||||
if (selectedSubject.value) await loadSubjectAccess()
|
||||
} catch (err: any) { showStatus(err.message, true) }
|
||||
}
|
||||
|
||||
// ── Access control (allow / deny ACL) ─────────────────────────────────────────
|
||||
// ── Access control (tree + allow/deny) ─────────────────────────────────────────
|
||||
type PermKey = 'canSearch' | 'canView' | 'canRead' | 'canEdit' | 'canCreate' | 'canDelete' | 'canMove'
|
||||
const ALL_PERMS: PermKey[] = ['canSearch', 'canView', 'canRead', 'canEdit', 'canCreate', 'canDelete', 'canMove']
|
||||
const PERM_LABELS: Record<PermKey, string> = {
|
||||
canSearch: 'Search', canView: 'View', canRead: 'Read', canEdit: 'Edit', canCreate: 'Create', canDelete: 'Delete', canMove: 'Move',
|
||||
}
|
||||
interface AclEntry { id: number; path: string; subjectType: string; subjectId: number; effect: string;
|
||||
const PERM_SHORT: Record<PermKey, string> = {
|
||||
canSearch: 'S', canView: 'V', canRead: 'R', canEdit: 'E', canCreate: 'C', canDelete: 'D', canMove: 'M',
|
||||
}
|
||||
interface AclEntry { id: number; path: string; effect: string;
|
||||
canSearch: boolean; canView: boolean; canRead: boolean; canEdit: boolean; canCreate: boolean; canDelete: boolean; canMove: boolean }
|
||||
interface PathAccess { path: string; canSearch: boolean; canView: boolean; canRead: boolean; canEdit: boolean; canCreate: boolean; canDelete: boolean; canMove: boolean }
|
||||
|
||||
const selectedSubject = ref<{ type: 'user' | 'group'; id: number; name: string; isLdap: boolean } | null>(null)
|
||||
const subjectAclEntries = ref<AclEntry[]>([])
|
||||
const editedPerms = ref<Record<number, Record<PermKey, boolean>>>({})
|
||||
const newEntry = ref<{ path: string; effect: 'allow' | 'deny'; perms: Record<PermKey, boolean> }>({
|
||||
path: '', effect: 'allow',
|
||||
perms: { canSearch: false, canView: false, canRead: false, canEdit: false, canCreate: false, canDelete: false, canMove: false },
|
||||
})
|
||||
const savingEntry = ref<number | null>(null)
|
||||
const addingEntry = ref(false)
|
||||
|
||||
const guestSelected = computed(() => selectedSubject.value?.type === 'user' && selectedSubject.value?.name === 'guest')
|
||||
|
||||
// Tree data (folders + documents in the repo).
|
||||
const treeFolders = ref<string[]>([])
|
||||
const treeDocs = ref<{ slug: string; title: string }[]>([])
|
||||
const openNodes = ref<Record<string, boolean>>({ '': true })
|
||||
|
||||
// Per-subject state.
|
||||
const rulesByPath = ref<Record<string, { allow?: AclEntry; deny?: AclEntry }>>({})
|
||||
const effectiveByPath = ref<Record<string, PathAccess>>({})
|
||||
const selectedNode = ref<string | null>(null)
|
||||
const editAllow = ref<Record<PermKey, boolean>>(blankPerms())
|
||||
const editDeny = ref<Record<PermKey, boolean>>(blankPerms())
|
||||
const savingNode = ref(false)
|
||||
|
||||
function blankPerms(): Record<PermKey, boolean> {
|
||||
return { canSearch: false, canView: false, canRead: false, canEdit: false, canCreate: false, canDelete: false, canMove: false }
|
||||
}
|
||||
|
||||
interface FolderNode { name: string; path: string; folders: FolderNode[]; docs: { slug: string; title: string }[] }
|
||||
function buildTree(): FolderNode {
|
||||
const root: FolderNode = { name: '', path: '', folders: [], docs: [] }
|
||||
for (const f of treeFolders.value) {
|
||||
const parts = f.split('/'); let node = root
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts.slice(0, i + 1).join('/')
|
||||
let c = node.folders.find(x => x.path === p)
|
||||
if (!c) { c = { name: parts[i], path: p, folders: [], docs: [] }; node.folders.push(c) }
|
||||
node = c
|
||||
}
|
||||
}
|
||||
for (const d of treeDocs.value) {
|
||||
const parts = d.slug.split('/')
|
||||
if (parts.length === 1) { root.docs.push(d); continue }
|
||||
let node = root
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const p = parts.slice(0, i + 1).join('/')
|
||||
let c = node.folders.find(x => x.path === p)
|
||||
if (!c) { c = { name: parts[i], path: p, folders: [], docs: [] }; node.folders.push(c) }
|
||||
node = c
|
||||
}
|
||||
node.docs.push(d)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
interface Row { type: 'folder' | 'doc'; label: string; path: string; depth: number; hasChildren: boolean }
|
||||
const flatTree = computed<Row[]>(() => {
|
||||
const root = buildTree()
|
||||
const rows: Row[] = []
|
||||
rows.push({ type: 'folder', label: '(root)', path: '', depth: 0, hasChildren: root.folders.length + root.docs.length > 0 })
|
||||
function traverse(node: FolderNode, depth: number) {
|
||||
for (const f of node.folders) {
|
||||
rows.push({ type: 'folder', label: f.name, path: f.path, depth, hasChildren: f.folders.length + f.docs.length > 0 })
|
||||
if (openNodes.value[f.path]) traverse(f, depth + 1)
|
||||
}
|
||||
for (const d of node.docs) {
|
||||
rows.push({ type: 'doc', label: d.title || d.slug.split('/').pop() || d.slug, path: d.slug, depth, hasChildren: false })
|
||||
}
|
||||
}
|
||||
if (openNodes.value[''] !== false) traverse(root, 1)
|
||||
return rows
|
||||
})
|
||||
|
||||
function toggleNode(path: string) {
|
||||
openNodes.value = { ...openNodes.value, [path]: !openNodes.value[path] }
|
||||
}
|
||||
|
||||
function allNodePaths(): string[] {
|
||||
return ['', ...treeFolders.value, ...treeDocs.value.map(d => d.slug)]
|
||||
}
|
||||
|
||||
function effLetters(path: string): string {
|
||||
const a = effectiveByPath.value[path]
|
||||
if (!a) return '—'
|
||||
const on = ALL_PERMS.filter(k => (a as any)[k]).map(k => PERM_SHORT[k])
|
||||
return on.length ? on.join(' ') : '—'
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
try {
|
||||
const [fData, dData] = await Promise.all([
|
||||
gql<{ folders: string[] }>(`{ folders }`),
|
||||
gql<{ documents: { slug: string; title: string }[] }>(`{ documents { slug title } }`),
|
||||
])
|
||||
treeFolders.value = fData.folders ?? []
|
||||
treeDocs.value = dData.documents ?? []
|
||||
const open: Record<string, boolean> = { '': true }
|
||||
for (const f of treeFolders.value) open[f] = true
|
||||
openNodes.value = open
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
async function selectSubject(type: 'user' | 'group', id: number, name: string, isLdap: boolean) {
|
||||
selectedSubject.value = { type, id, name, isLdap }
|
||||
await loadSubjectAcl()
|
||||
selectedNode.value = null
|
||||
await Promise.all([loadSubjectAcl(), loadSubjectAccess()])
|
||||
}
|
||||
|
||||
async function loadSubjectAcl() {
|
||||
if (!selectedSubject.value) return
|
||||
try {
|
||||
const data = await gql<{ subjectAcl: AclEntry[] }>(
|
||||
`query SubjectAcl($t: String!, $id: Int!) { subjectAcl(subjectType: $t, subjectId: $id) { id path subjectType subjectId effect canSearch canView canRead canEdit canCreate canDelete canMove } }`,
|
||||
`query SubjectAcl($t: String!, $id: Int!) { subjectAcl(subjectType: $t, subjectId: $id) { id path effect canSearch canView canRead canEdit canCreate canDelete canMove } }`,
|
||||
{ t: selectedSubject.value.type, id: selectedSubject.value.id })
|
||||
subjectAclEntries.value = data.subjectAcl
|
||||
const perms: Record<number, Record<PermKey, boolean>> = {}
|
||||
const map: Record<string, { allow?: AclEntry; deny?: AclEntry }> = {}
|
||||
for (const e of data.subjectAcl) {
|
||||
perms[e.id] = { canSearch: e.canSearch, canView: e.canView, canRead: e.canRead, canEdit: e.canEdit, canCreate: e.canCreate, canDelete: e.canDelete, canMove: e.canMove }
|
||||
if (!map[e.path]) map[e.path] = {}
|
||||
if (e.effect === 'deny') map[e.path].deny = e; else map[e.path].allow = e
|
||||
}
|
||||
editedPerms.value = perms
|
||||
rulesByPath.value = map
|
||||
} catch (err: any) { showStatus(err.message, true) }
|
||||
}
|
||||
async function saveEntryPerms(entry: AclEntry) {
|
||||
|
||||
async function loadSubjectAccess() {
|
||||
if (!selectedSubject.value) return
|
||||
savingEntry.value = entry.id
|
||||
try {
|
||||
await gql(`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`,
|
||||
{ input: { path: entry.path, subjectType: selectedSubject.value.type, subjectId: selectedSubject.value.id, effect: entry.effect, ...editedPerms.value[entry.id] } })
|
||||
showStatus('Permission saved.'); await loadSubjectAcl()
|
||||
} catch (err: any) { showStatus(err.message, true) } finally { savingEntry.value = null }
|
||||
const data = await gql<{ subjectAccess: PathAccess[] }>(
|
||||
`query SA($t: String!, $id: Int!, $p: [String!]!) { subjectAccess(subjectType: $t, subjectId: $id, paths: $p) { path canSearch canView canRead canEdit canCreate canDelete canMove } }`,
|
||||
{ t: selectedSubject.value.type, id: selectedSubject.value.id, p: allNodePaths() })
|
||||
const map: Record<string, PathAccess> = {}
|
||||
for (const a of data.subjectAccess) map[a.path] = a
|
||||
effectiveByPath.value = map
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
async function addNewEntry() {
|
||||
if (!newEntry.value.path.trim() || !selectedSubject.value) { showStatus('Enter a path first.', true); return }
|
||||
addingEntry.value = true
|
||||
try {
|
||||
await gql(`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`,
|
||||
{ input: { path: newEntry.value.path.trim(), subjectType: selectedSubject.value.type, subjectId: selectedSubject.value.id, effect: newEntry.value.effect, ...newEntry.value.perms } })
|
||||
showStatus('Permission added.')
|
||||
newEntry.value = { path: '', effect: 'allow', perms: { canSearch: false, canView: false, canRead: false, canEdit: false, canCreate: false, canDelete: false, canMove: false } }
|
||||
await loadSubjectAcl()
|
||||
} catch (err: any) { showStatus(err.message, true) } finally { addingEntry.value = false }
|
||||
|
||||
function selectNode(path: string) {
|
||||
selectedNode.value = path
|
||||
const r = rulesByPath.value[path] || {}
|
||||
editAllow.value = r.allow ? pick(r.allow) : blankPerms()
|
||||
editDeny.value = r.deny ? pick(r.deny) : blankPerms()
|
||||
}
|
||||
async function removeEntry(id: number) {
|
||||
function pick(e: AclEntry): Record<PermKey, boolean> {
|
||||
const o = blankPerms()
|
||||
for (const k of ALL_PERMS) o[k] = (e as any)[k]
|
||||
return o
|
||||
}
|
||||
|
||||
async function saveNode() {
|
||||
if (!selectedSubject.value || selectedNode.value === null) return
|
||||
savingNode.value = true
|
||||
try {
|
||||
await gql(`mutation RemoveAcl($id: Int!) { removeAcl(id: $id) }`, { id })
|
||||
subjectAclEntries.value = subjectAclEntries.value.filter(e => e.id !== id); showStatus('Permission removed.')
|
||||
} catch (err: any) { showStatus(err.message, true) }
|
||||
const path = selectedNode.value
|
||||
const subj = { subjectType: selectedSubject.value.type, subjectId: selectedSubject.value.id }
|
||||
const existing = rulesByPath.value[path] || {}
|
||||
if (ALL_PERMS.some(k => editAllow.value[k])) {
|
||||
await gql(`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`, { input: { path, ...subj, effect: 'allow', ...editAllow.value } })
|
||||
} else if (existing.allow) {
|
||||
await gql(`mutation RA($id: Int!) { removeAcl(id: $id) }`, { id: existing.allow.id })
|
||||
}
|
||||
if (ALL_PERMS.some(k => editDeny.value[k])) {
|
||||
await gql(`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`, { input: { path, ...subj, effect: 'deny', ...editDeny.value } })
|
||||
} else if (existing.deny) {
|
||||
await gql(`mutation RA($id: Int!) { removeAcl(id: $id) }`, { id: existing.deny.id })
|
||||
}
|
||||
showStatus('Rules saved.')
|
||||
await Promise.all([loadSubjectAcl(), loadSubjectAccess()])
|
||||
} catch (err: any) { showStatus(err.message, true) } finally { savingNode.value = false }
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
@@ -299,7 +408,7 @@ onMounted(async () => {
|
||||
ldapPasswordSet.value = true
|
||||
}
|
||||
} catch (err: any) { showStatus(err.message, true) } finally { loading.value = false }
|
||||
await Promise.all([loadOidc(), loadUsers(), loadSubjects()])
|
||||
await Promise.all([loadOidc(), loadUsers(), loadSubjects(), loadTree()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -460,18 +569,19 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Access control -->
|
||||
<!-- Access control (tree) -->
|
||||
<section class="card p-6">
|
||||
<h2 class="text-lg font-semibold mb-2 border-b border-slate-200 dark:border-slate-700 pb-2">Access Control</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-1">
|
||||
Default is <strong>deny</strong>. An <span class="text-green-600 dark:text-green-400 font-medium">allow</span> grants access to a path (and everything under it);
|
||||
a <span class="text-red-600 dark:text-red-400 font-medium">deny</span> always wins over an allow.
|
||||
Default is <strong>deny</strong>. An <span class="text-green-600 dark:text-green-400 font-medium">allow</span> grants access to a node (and everything under it);
|
||||
a <span class="text-red-600 dark:text-red-400 font-medium">deny</span> always wins. Rules combine across the user's groups and parent folders.
|
||||
</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-5">
|
||||
Paths are document slugs (e.g. <code class="font-mono text-xs bg-slate-100 dark:bg-slate-800 px-1 rounded">docs/intro</code>) or folders (e.g. <code class="font-mono text-xs bg-slate-100 dark:bg-slate-800 px-1 rounded">docs</code>).
|
||||
Select the <strong>public</strong> user to control what anonymous visitors can see.
|
||||
Pick a user or group, then click a folder/file in the tree to set its rule. Letters after each node show the subject's <em>effective</em> rights there
|
||||
(<span class="font-mono">S V R E C D M</span> = Search View Read Edit Create Delete Move).
|
||||
</p>
|
||||
|
||||
<!-- Subject pickers -->
|
||||
<div class="grid sm:grid-cols-2 gap-4 mb-6">
|
||||
<div class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<div class="bg-slate-50 dark:bg-slate-800/60 px-3 py-2 border-b border-slate-200 dark:border-slate-700 text-sm font-semibold">Users <span class="text-xs text-slate-400 ml-1">{{ subjectUsers.length }}</span></div>
|
||||
@@ -499,51 +609,68 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSubject" class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<div class="bg-slate-50 dark:bg-slate-800/60 px-4 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center gap-2 text-sm font-semibold">
|
||||
{{ selectedSubject.type === 'user' ? '👤' : '👥' }} {{ selectedSubject.name }}
|
||||
<span class="ml-auto text-xs text-slate-400">{{ subjectAclEntries.length }} rule{{ subjectAclEntries.length !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<p v-if="guestSelected" class="px-4 py-2 text-xs text-purple-600 dark:text-purple-400 bg-purple-50/50 dark:bg-purple-900/10 border-b border-slate-200 dark:border-slate-700">
|
||||
These rules define what anonymous visitors (the public user) can see.
|
||||
</p>
|
||||
|
||||
<div class="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||
<div v-if="subjectAclEntries.length === 0" class="px-4 py-6 text-sm text-slate-400 text-center italic">No rules yet. Add one below.</div>
|
||||
<div v-for="entry in subjectAclEntries" :key="entry.id" class="px-4 py-3">
|
||||
<div class="flex items-start gap-3 flex-wrap">
|
||||
<span :class="['text-xs font-semibold px-2 py-0.5 rounded mt-0.5', entry.effect === 'deny' ? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400' : 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400']">{{ entry.effect }}</span>
|
||||
<code class="text-sm font-mono bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded flex-shrink-0 mt-0.5">{{ entry.path }}</code>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 flex-1 min-w-0">
|
||||
<label v-for="perm in ALL_PERMS" :key="perm" class="flex items-center gap-1 text-xs cursor-pointer text-slate-600 dark:text-slate-400">
|
||||
<input type="checkbox" v-model="editedPerms[entry.id][perm]" class="rounded border-slate-300 text-accent-600" />
|
||||
{{ PERM_LABELS[perm] }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<button class="btn-primary text-xs py-1 px-3" :disabled="savingEntry === entry.id" @click="saveEntryPerms(entry)">{{ savingEntry === entry.id ? 'Saving…' : 'Save' }}</button>
|
||||
<button class="btn-ghost text-xs py-1 px-2 text-red-500 hover:text-red-700" @click="removeEntry(entry.id)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedSubject" class="grid md:grid-cols-2 gap-4">
|
||||
<!-- Tree -->
|
||||
<div class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<div class="bg-slate-50 dark:bg-slate-800/60 px-3 py-2 border-b border-slate-200 dark:border-slate-700 text-sm font-semibold flex items-center gap-2">
|
||||
<span>📂 Tree</span>
|
||||
<span class="ml-auto text-xs text-slate-400">{{ selectedSubject.name }}</span>
|
||||
</div>
|
||||
<ul class="max-h-96 overflow-y-auto py-1 text-sm">
|
||||
<li v-for="row in flatTree" :key="row.type + ':' + row.path"
|
||||
:style="{ paddingLeft: `${0.4 + row.depth * 0.9}rem` }"
|
||||
:class="['flex items-center gap-1.5 pr-2 py-1 cursor-pointer', selectedNode === row.path ? 'bg-accent-500/10' : 'hover:bg-slate-50 dark:hover:bg-slate-800/60']"
|
||||
@click="selectNode(row.path)">
|
||||
<button v-if="row.type === 'folder' && row.hasChildren" class="w-3.5 flex-shrink-0" @click.stop="toggleNode(row.path)">
|
||||
<svg class="w-3 h-3 transition-transform" :class="openNodes[row.path] ? 'rotate-90' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
<span v-else class="w-3.5 flex-shrink-0"></span>
|
||||
<span class="flex-shrink-0">{{ row.type === 'folder' ? (row.path === '' ? '🏠' : '📁') : '📄' }}</span>
|
||||
<span class="truncate" :class="row.path === '' ? 'font-semibold' : ''">{{ row.label }}</span>
|
||||
<span v-if="rulesByPath[row.path]?.allow" class="text-[10px] px-1 rounded bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 flex-shrink-0">allow</span>
|
||||
<span v-if="rulesByPath[row.path]?.deny" class="text-[10px] px-1 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 flex-shrink-0">deny</span>
|
||||
<span class="ml-auto text-[10px] font-mono text-slate-400 flex-shrink-0">{{ effLetters(row.path) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/30 px-4 py-4">
|
||||
<h4 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Add rule</h4>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="label text-xs">Effect</label>
|
||||
<select v-model="newEntry.effect" class="input text-sm w-auto"><option value="allow">allow</option><option value="deny">deny</option></select>
|
||||
</div>
|
||||
<div class="flex-1 min-w-40"><label class="label text-xs">Path</label><input v-model="newEntry.path" class="input text-sm" placeholder="docs/intro or docs" @keydown.enter="addNewEntry" /></div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<label v-for="perm in ALL_PERMS" :key="perm" class="flex items-center gap-1 text-xs cursor-pointer text-slate-600 dark:text-slate-400">
|
||||
<input type="checkbox" v-model="newEntry.perms[perm]" class="rounded border-slate-300 text-accent-600" />
|
||||
<!-- Node editor -->
|
||||
<div v-if="selectedNode !== null" class="border border-slate-200 dark:border-slate-700 rounded-lg p-4">
|
||||
<div class="text-sm font-semibold mb-1">
|
||||
{{ selectedNode === '' ? '🏠 (root — everything)' : selectedNode }}
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mb-3">
|
||||
Effective here: <span class="font-mono">{{ effLetters(selectedNode) }}</span>
|
||||
</p>
|
||||
<p v-if="guestSelected" class="text-xs text-purple-600 dark:text-purple-400 mb-3">
|
||||
Rules for the public (anonymous) user.
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="text-xs font-semibold text-green-600 dark:text-green-400 mb-1">Allow</div>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<label v-for="perm in ALL_PERMS" :key="'a'+perm" class="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input type="checkbox" v-model="editAllow[perm]" class="rounded border-slate-300 text-green-600" />
|
||||
{{ PERM_LABELS[perm] }}
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn-primary text-sm py-1.5 px-4" :disabled="addingEntry || !newEntry.path.trim()" @click="addNewEntry">{{ addingEntry ? 'Adding…' : 'Add' }}</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="text-xs font-semibold text-red-600 dark:text-red-400 mb-1">Deny (wins over allow)</div>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<label v-for="perm in ALL_PERMS" :key="'d'+perm" class="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input type="checkbox" v-model="editDeny[perm]" class="rounded border-slate-300 text-red-600" />
|
||||
{{ PERM_LABELS[perm] }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button class="btn-primary text-sm py-1.5 px-4" :disabled="savingNode" @click="saveNode">{{ savingNode ? 'Saving…' : 'Save rules for this node' }}</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-400 mt-2">Uncheck everything and save to clear a rule. Rules on a folder apply to everything under it.</p>
|
||||
</div>
|
||||
<div v-else class="border border-dashed border-slate-300 dark:border-slate-700 rounded-lg p-4 flex items-center justify-center text-sm text-slate-400 text-center">
|
||||
Click a folder or file in the tree to set its rules for <strong class="mx-1">{{ selectedSubject.name }}</strong>.
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-slate-400 italic text-center mt-2">Select a user or group to manage their access.</p>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { gql } from '@/lib/gql'
|
||||
import { fetchMyAccess, type PathAccess } from '@/lib/access'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import VisualEditor from '@/components/editor/VisualEditor.vue'
|
||||
import SourceEditor from '@/components/editor/SourceEditor.vue'
|
||||
import HistoryPanel from '@/components/history/HistoryPanel.vue'
|
||||
@@ -10,11 +12,33 @@ import type { CommitEntry } from '@/components/history/HistoryPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
const slug = computed(() => {
|
||||
const s = route.params.slug
|
||||
return Array.isArray(s) ? s.join('/') : (s as string)
|
||||
})
|
||||
|
||||
// ── Permission gating ──────────────────────────────────────────────────────
|
||||
const isAdmin = computed(() => app.role === 'admin')
|
||||
const docAccess = ref<PathAccess | null>(null)
|
||||
// New docs are reached via a create-gated action, so editing is allowed there.
|
||||
const canEditDoc = computed(() => isAdmin.value || slug.value === 'new' || !!docAccess.value?.canEdit)
|
||||
const canDeleteDoc = computed(() => slug.value !== 'new' && (isAdmin.value || !!docAccess.value?.canDelete))
|
||||
const deleting = ref(false)
|
||||
|
||||
async function deleteDoc() {
|
||||
if (!confirm(`Delete "${slug.value}"? This cannot be undone.`)) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await gql(`mutation Del($slug: String!) { deleteDocument(slug: $slug) }`, { slug: slug.value })
|
||||
router.push('/')
|
||||
} catch (err: any) {
|
||||
alert(err?.message || 'Failed to delete')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normal editing state ──────────────────────────────────────────────────────
|
||||
|
||||
const docPath = ref('')
|
||||
@@ -30,6 +54,7 @@ async function loadDocument(s: string) {
|
||||
exitVersionView()
|
||||
exitDiffView()
|
||||
|
||||
docAccess.value = null
|
||||
if (s === 'new') {
|
||||
content.value = ''
|
||||
docPath.value = ''
|
||||
@@ -47,6 +72,12 @@ async function loadDocument(s: string) {
|
||||
{ s },
|
||||
)
|
||||
content.value = data.document?.content ?? ''
|
||||
if (!isAdmin.value) {
|
||||
try {
|
||||
const acc = await fetchMyAccess([s])
|
||||
docAccess.value = acc[s] ?? null
|
||||
} catch { /* leave null → buttons hidden */ }
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -230,8 +261,8 @@ function formatDate(dateStr: string) {
|
||||
placeholder="New filename (e.g. folder/doc)"
|
||||
/>
|
||||
|
||||
<!-- Save controls (normal editing only) -->
|
||||
<template v-if="!viewingVersion && !diffActive">
|
||||
<!-- Save controls (normal editing only, when the user may edit) -->
|
||||
<template v-if="!viewingVersion && !diffActive && canEditDoc">
|
||||
<input
|
||||
v-model="commitMsg"
|
||||
class="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded px-2 py-1 text-sm w-64 text-slate-900 dark:text-slate-100"
|
||||
@@ -244,6 +275,20 @@ function formatDate(dateStr: string) {
|
||||
>{{ saving ? 'Saving…' : 'Save' }}</button>
|
||||
</template>
|
||||
|
||||
<!-- Read-only hint when editing is not permitted -->
|
||||
<span
|
||||
v-else-if="!viewingVersion && !diffActive && !canEditDoc"
|
||||
class="text-xs text-slate-400 italic px-2"
|
||||
>Read-only</span>
|
||||
|
||||
<!-- Delete (only when permitted) -->
|
||||
<button
|
||||
v-if="!viewingVersion && !diffActive && canDeleteDoc"
|
||||
class="px-3 py-1 bg-red-700 hover:bg-red-600 text-white rounded text-sm disabled:opacity-50"
|
||||
:disabled="deleting"
|
||||
@click="deleteDoc"
|
||||
>{{ deleting ? 'Deleting…' : 'Delete' }}</button>
|
||||
|
||||
<!-- History toggle button (not shown for new docs or in diff view) -->
|
||||
<button
|
||||
v-if="slug !== 'new'"
|
||||
|
||||
Reference in New Issue
Block a user