@@ -1,5 +1,5 @@
< script setup lang = "ts" >
import { ref , computed , onMounted } from 'vue'
import { ref , onMounted } from 'vue'
import { gql } from '@/lib/gql'
import FolderPicker from '@/components/FolderPicker.vue'
@@ -31,11 +31,6 @@ const ldapBrowsing = ref(false)
type AclSubject = { id : number ; name : string ; isLdap : boolean }
const aclSubjectUsers = ref < AclSubject [ ] > ( [ ] )
const aclSubjectGroups = ref < AclSubject [ ] > ( [ ] )
const aclPath = ref ( '' )
const aclSubjectType = ref < 'user' | 'group' > ( 'user' )
const aclSubjectId = ref < number | null > ( null )
const aclPerms = ref ( { canSearch : false , canView : false , canRead : false , canEdit : false , canCreate : false , canDelete : false , canMove : false } )
const aclEntries = ref < any [ ] > ( [ ] )
onMounted ( async ( ) => {
try {
@@ -178,6 +173,10 @@ async function importSubject(type: 'user' | 'group', name: string) {
await gql ( ` mutation ImportSubj( $ type: String!, $ name: String!) { importLdapSubject(type: $ type, name: $ name) } ` , { type , name } )
showStatus ( ` Imported ${ type } ${ name } successfully. ` )
await loadAclSubjects ( )
// If the just-imported subject is selected, refresh its ACL view
if ( selectedSubject . value && selectedSubject . value . type === type && selectedSubject . value . name === name ) {
await loadSubjectAcl ( )
}
} catch ( err : any ) {
showStatus ( err . message , true )
}
@@ -201,54 +200,109 @@ async function savePassword() {
// ── ACL Management ─────────────────────────────────────────────────────────────
const aclSubjects = computed ( ( ) =>
aclSubjectType . value === 'user' ? aclSubjectUsers . value : aclSubjectGroups . value
)
interface AclEntry {
id : number
path : string
subjectType : string
subjectId : number
canSearch : boolean
canView : boolean
canRead : boolean
canEdit : boolean
canCreate : boolean
canDelete : boolean
canMove : boolean
}
async function loadAcl ( ) {
if ( ! aclPath . value . trim ( ) ) { showStatus ( 'Enter a path first.' , true ) ; return }
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'
}
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 newEntryPath = ref ( '' )
const newEntryPerms = ref < Record < PermKey , boolean > > ( {
canSearch : false , canView : false , canRead : false ,
canEdit : false , canCreate : false , canDelete : false , canMove : false
} )
const savingEntry = ref < number | null > ( null )
const addingEntry = ref ( false )
async function selectSubject ( type : 'user' | 'group' , id : number , name : string , isLdap : boolean ) {
selectedSubject . value = { type , id , name , isLdap }
await loadSubjectAcl ( )
}
async function loadSubjectAcl ( ) {
if ( ! selectedSubject . value ) return
try {
const data = await gql < { a cl: an y[ ] } > (
` query Ge tAcl( $ path : String!) { acl(path: $ path ) { id path subjectType subjectId canSearch canView canRead canEdit canCreate canDelete canMove } }` ,
{ path : aclPath . value . trim ( ) }
const data = await gql < { subjectA cl: AclEntr y[ ] } > (
` query Subjec tAcl( $ t : String!, $ id: Int!) { subjectAcl(subjectType: $ t, subjectId: $ id ) { id path subjectType subjectId canSearch canView canRead canEdit canCreate canDelete canMove } }` ,
{ t : selectedSubject . value . type , id : selectedSubject . value . id }
)
a clEntries. value = data . a cl
subjectA clEntries. value = data . subjectA cl
// Initialise editable copies
const perms : Record < number , Record < PermKey , boolean > > = { }
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 }
}
editedPerms . value = perms
} catch ( err : any ) {
showStatus ( err . message , true )
}
}
async function saveAcl ( ) {
if ( ! aclPath . value . trim ( ) || acl SubjectId . value === null ) {
showStatus ( 'Select a path and a subject.' , true )
return
}
async function saveEntryPerms ( entry : AclEntry ) {
if ( ! selected Subject. value ) return
savingEntry . value = entry . id
try {
await gql (
` mutation SetAcl( $ input: ACLInput!) { setAcl(input: $ input) } ` ,
{ input : { path : aclPath . value . trim ( ) , subjectType : acl SubjectType . value , subjectId : acl SubjectId . value , ... acl Perms. value } }
{ input : { path : entry . path , subjectType : selected Subject. value . type , subjectId : selected Subject. value . id , ... edited Perms. value [ entry . id ] } }
)
showStatus ( 'Permission saved.' )
await loadAcl ( )
await loadSubject Acl ( )
} catch ( err : any ) {
showStatus ( err . message , true )
} finally {
savingEntry . value = null
}
}
async function removeAcl ( id : number ) {
async function addNewEntry ( ) {
if ( ! newEntryPath . value . 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 : newEntryPath . value . trim ( ) , subjectType : selectedSubject . value . type , subjectId : selectedSubject . value . id , ... newEntryPerms . value } }
)
showStatus ( 'Permission added.' )
newEntryPath . value = ''
newEntryPerms . value = { 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
}
}
async function removeEntry ( id : number ) {
try {
await gql ( ` mutation RemoveAcl( $ id: Int!) { removeAcl(id: $ id) } ` , { id } )
a clEntries. value = a clEntries. value . filter ( e => e . id !== id )
subjectA clEntries. value = subjectA clEntries. value . filter ( e => e . id !== id )
showStatus ( 'Permission removed.' )
} catch ( err : any ) {
showStatus ( err . message , true )
}
}
function subjectName ( type : string , id : number ) : string {
const list = type === 'user' ? aclSubjectUsers . value : aclSubjectGroups . value
return list . find ( s => s . id === id ) ? . name ? ? String ( id )
}
< / script >
< template >
@@ -396,7 +450,7 @@ function subjectName(type: string, id: number): string {
< ul class = "max-h-48 overflow-y-auto space-y-1" >
< li v-for = "u in ldapUsers" :key="u" class="flex justify-between items-center text-sm p-1 hover:bg-slate-50 dark:hover:bg-slate-800 rounded" >
< span class = "truncate" > { { u } } < / span >
< button class = "text-accent-600 hover:text-accent-700 text-xs" @click ="importSubject('user', u)" > Import < / button >
< button class = "text-accent-600 hover:text-accent-700 text-xs font-medium px-2 py-0.5 rounded hover:bg-accent-50 dark:hover:bg-accent-500/10 transition " @click ="importSubject('user', u)" > Import < / button >
< / li >
< / ul >
< / div >
@@ -405,7 +459,7 @@ function subjectName(type: string, id: number): string {
< ul class = "max-h-48 overflow-y-auto space-y-1" >
< li v-for = "g in ldapGroups" :key="g" class="flex justify-between items-center text-sm p-1 hover:bg-slate-50 dark:hover:bg-slate-800 rounded" >
< span class = "truncate" > { { g } } < / span >
< button class = "text-accent-600 hover:text-accent-700 text-xs" @click ="importSubject('group', g)" > Import < / button >
< button class = "text-accent-600 hover:text-accent-700 text-xs font-medium px-2 py-0.5 rounded hover:bg-accent-50 dark:hover:bg-accent-500/10 transition " @click ="importSubject('group', g)" > Import < / button >
< / li >
< / ul >
< / div >
@@ -416,88 +470,164 @@ function subjectName(type: string, id: number): string {
< ! - - ACL Management - - >
< section class = "card p-6" >
< h2 class = "text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2" > Access Control ( ACL ) < / h2 >
< p class = "text-sm text-slate-500 dark:text-slate-400 mb-4 " >
Set file / folder permissions for users and group s. The < strong > guest < / strong > user has no access by default — you must explicitly grant it here . Paths are document slugs ( e . g . < code > docs / intro < / code > ) or folder names ( e . g . < code > docs < / code > ) .
< h2 class = "text-lg font-semibold mb-2 border-b border-slate-200 dark:border-slate-700 pb-2" > Access Control ( ACL ) < / h2 >
< p class = "text-sm text-slate-500 dark:text-slate-400 mb-5 " >
Selec t a user or group to view and edit their permission s. The < strong > guest < / strong > user has no access by default .
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 folder names ( e . g . < code class = "font-mono text-xs bg-slate-100 dark:bg-slate-800 px-1 rounded" > docs < / code > ) .
< / p >
<!-- Path lookup -- >
< div class = "flex gap-2 mb-6" >
< input v-model = "aclPath" class="input flex-1" placeholder="docs/intro or docs" @keydown.enter="loadAcl" / >
< button class = "btn-secondary" @click ="loadAcl" > Load ACL < / button >
< / div >
<!-- Subject lists -- >
< div class = "grid sm:grid-cols-2 gap-4 mb-6" >
<!-- Users -- >
< 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 flex items-center gap-2" >
< svg class = "w-4 h-4 text-slate-500" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" / >
< / svg >
< span class = "text-sm font-semibold text-slate-700 dark:text-slate-300" > Users < / span >
< span class = "ml-auto text-xs text-slate-400" > { { aclSubjectUsers . length } } < / span >
< / div >
< ul class = "max-h-52 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-700/50" >
< li v-if = "aclSubjectUsers.length === 0" class="px-3 py-4 text-sm text-slate-400 text-center italic" > No users < / li >
< li
v-for = "u in aclSubjectUsers" :key="u.id"
: class = "[
'flex items-center gap-2 px-3 py-2 cursor-pointer text-sm transition',
selectedSubject?.type === 'user' && selectedSubject?.id === u.id
? 'bg-accent-500/10 text-accent-700 dark:text-accent-400 font-medium'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/60'
]"
@click ="selectSubject('user', u.id, u.name, u.isLdap)"
>
< span class = "truncate flex-1" > { { u . name } } < / span >
< span v-if = "u.isLdap" class="text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex-shrink-0" > LDAP < / span >
< / li >
< / ul >
< / div >
< ! - - Existing entrie s - - >
< div v-if = "aclEntries.length" class="mb-6" >
< h3 class = "text-sm font-semibold text -slate-70 0 dark:text -slate-3 00 mb-2" > Current permissions for < code class = "text-accent-600" > { { aclPath } } < / code > < / h3 >
< div class = "overflow-x-auto " >
< table class = "w-full text-xs border-collapse ">
< thead >
< tr class = "text-left text-slate-5 00" >
< th class = "pb-1 pr-3" > Subject < / th >
< th class = "pb-1 pr-2" > Search < / th >
< th class = "pb-1 pr-2" > View < / th >
< th class = "pb-1 pr-2" > Read < / th >
< th class = "pb-1 pr-2" > Edit < / th >
< th class = "pb-1 pr-2" > Create < / th >
< th class = "pb-1 pr-2" > Delete < / th >
< th class = "pb-1 pr-2" > Move < / th >
< th class = "pb-1" > < / th >
< / tr >
< / thead >
< tbody >
< tr v-for = "e in aclEntries" :key="e.id" class="border-t border-slate-100 dark:border-slate-700 ">
< td class = "py-1 pr-3 font-medium" > { { e . subjectType === 'user' ? '👤' : '👥' } } { { subjectName ( e . subjectType , e . subjectId ) } } < / td >
< td class = "py-1 pr-2 text-center " > { { e . canSearch ? '✓' : '– ' } } < / td >
< td class = "py-1 pr-2 text-center" > { { e . canView ? '✓' : '– ' } } < / td >
< td class = "py-1 pr-2 text-center" > { { e . canRead ? '✓' : '– ' } } < / td >
< td class = "py-1 pr-2 text-center" > { { e . canEdit ? '✓' : '– ' } } < / td >
< td class = "py-1 pr-2 text-center" > { { e . canCreate ? '✓' : '– ' } } < / td >
< td class = "py-1 pr-2 text-center" > { { e . canDelete ? '✓' : '– ' } } < / td >
< td class = "py-1 pr-2 text-center" > { { e . canMove ? '✓' : '– ' } } < / td >
< td class = "py-1" >
< button class = "text-red-500 hover:text-red-700 text-xs" @click ="removeAcl(e.id)" > Remove < / button >
< / td >
< / tr >
< / tbody >
< / table >
< ! - - Group s - - >
< div class = "border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden" >
< div class = "bg -slate-5 0 dark:bg -slate-8 00/60 px-3 py-2 border-b border-slate-200 dark:border-slate-700 flex items-center gap-2" >
< svg class = "w-4 h-4 text-slate-500" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24 " >
< path stroke -linecap = " round " stroke -linejoin = " round " stroke -width = " 2 " d = "M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z " / >
< / svg >
< span class = "text-sm font-semibold text-slate-700 dark: text-slate-3 00" > Groups < / span >
< span class = "ml-auto text-xs text-slate-400" > { { aclSubjectGroups . length } } < / span >
< / div >
< ul class = "max-h-52 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-700/50" >
< li v-if = "aclSubjectGroups.length === 0" class="px-3 py-4 text-sm text-slate-400 text-center italic" > No groups < / li >
< li
v-for = "g in aclSubjectGroups" :key="g.id"
: class = "[
'flex items-center gap-2 px-3 py-2 cursor-pointer text-sm transition',
selectedSubject?.type === 'group' && selectedSubject?.id === g.id
? 'bg-accent-500/10 text-accent-700 dark:text-accent-400 font-medium'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/60'
]"
@click ="selectSubject('group', g.id, g.name, g.isLdap) "
>
< span class = "truncate flex-1 " > { { g . name } } < / span >
< span v-if = "g.isLdap" class="text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex-shrink-0" > LDAP < / span >
< / li >
< / ul >
< / div >
< / div >
< ! - - Add / upda te entry - - >
< div class = "border-t border-slate-200 dark:border-slate-700 pt-4" >
< h3 class ="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3" > Add / update permission < / h3 >
< div class ="grid gap-3 sm:grid-cols-2" >
< div >
< label class = "label" > Type < / label >
< select v-model = "acl SubjectType " class="input" @change="aclSubjectId = null " >
< option value = "user" > User < / option >
< option value = "group" > Group < / option >
< / select >
< ! - - Detail panel for selec ted subject - - >
< transition
enter -active - class =" transition duration -150 ease -out "
enter -from - class =" opacity -0 translate -y -1 "
enter -to -class = " opacity -100 translate -y -0 "
>
< div v-if = "selected Subject" class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden ">
< ! - - Detail header - - >
< 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" >
< span class = "text-sm font-semibold text-slate-800 dark:text-slate-200" >
{ { selectedSubject . type === 'user' ? '👤' : '👥' } } { { selectedSubject . name } }
< / span >
< span v-if = "selectedSubject.isLdap" class="text-xs px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400" > LDAP < / span >
< span class = "ml-auto text-xs text-slate-400" > { { subjectAclEntries . length } } permission { { subjectAclEntries . length !== 1 ? 's' : '' } } < / span >
< / div >
< div >
< label class = "label" > Subject < / label >
< select v-model = "aclSubjectId" class="input" >
< option :value = "null" disabled > — select — < / option >
< opt ion v-for = "s in aclSubjects" :key="s.id" :value="s.id" >
{{ s.name }}{{ s.isLdap ? ' ( LDAP ) ' : ' ' }}
< / option >
< / select >
< / div >
< div class = "sm:col-span-2" >
< label class = "label" > Permissions < / label >
< div class = "flex flex-wrap gap-4 mt-1 " >
< label v-for = "perm in ['canSearch','canView','canRead','canEdit','canCreate','canDelete','canMove']" :key="perm" class="flex items-center gap-1 text-sm cursor-pointer" >
< input type = "checkbox" v-model = "(aclPerms as any)[perm]" class="rounded" / >
{ { perm . replace ( 'can' , '' ) } }
< / label >
<!-- Existing entries -- >
< 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 permiss ions set. 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" >
<!-- Path -- >
< code class = "text-sm font-mono text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-800 px-2 py-0.5 rounded flex-shrink-0 mt-0.5" > { { entry . path } } < / code >
<!-- Permission checkboxes -- >
< 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 focus:ring-accent-500"
/ >
{ { PERM _LABELS [ perm ] } }
< / label >
< / div >
<!-- Actions -- >
< 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 dark:hover:text-red-400" @click ="removeEntry(entry.id)" > Remove < / button >
< / div >
< / div >
< / div >
< / div >
< div class = "sm:col-span-2 flex justify-end" >
< button class = "btn-primary" @click ="saveAcl" > Save Permission < / button >
< ! - - Add new entry - - >
< 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 dark:text-slate-400 uppercase tracking-wider mb-3" > Add permission for a path < / h4 >
< div class = "flex flex-wrap items-end gap-3" >
< div class = "flex-1 min-w-40" >
< label class = "label text-xs" > Path < / label >
< input
v-model = "newEntryPath"
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 = "newEntryPerms[perm]"
class = "rounded border-slate-300 text-accent-600 focus:ring-accent-500"
/ >
{ { PERM _LABELS [ perm ] } }
< / label >
< / div >
< button
class = "btn-primary text-sm py-1.5 px-4 flex-shrink-0"
: disabled = "addingEntry || !newEntryPath.trim()"
@click ="addNewEntry"
>
{ { addingEntry ? 'Adding…' : 'Add' } }
< / button >
< / div >
< / div >
< / div >
< / div >
< / transition >
< p v-if = "!selectedSubject" class="text-sm text-slate-400 italic text-center mt-2" >
Select a user or group above to manage their permissions.
< / p >
< / section >
< / div >