partial implement (Save)

This commit is contained in:
2026-04-16 07:01:57 +02:00
parent d571f156a6
commit d808b0289f
8 changed files with 363 additions and 105 deletions

View File

@@ -160,6 +160,9 @@ Login-flöde (gäst):
Session-storage: in-memory map[token]*Session (försvinner vid omstart)
Session-livstid: 24 timmar (konfigurerat i auth.go)
**Rollbaserad UI-synlighet:**
Frontendens Pinia-store (`app.ts`) lagrar den inloggade användarens roll (`admin`, `user`, `guest`) via query `currentUserRole` som körs direkt efter inloggning. Admin-panelslänken i sidofältet visas bara om `role === 'admin'`.
```
**Viktigt:** Sessions lagras enbart i minnet. Vid server-omstart måste alla klienter logga in igen. LDAP-konfigurationsändringar (via admin-panelen) nollställer **inte** sessioner — befintliga sessioner är fortfarande giltiga.
@@ -419,13 +422,14 @@ Archivum/
type Query {
systemStatus: SystemStatus!
userAuthType(username: String!): String! # "local" | "ldap" | "guest"
currentUserRole: String! # Returnerar rollen för inloggad session ("admin" | "user" | "guest")
documents(prefix: String): [DocumentMeta!]!
document(slug: String!): Document
folders: [String!]!
history(slug: String!): [CommitEntry!]!
diff(slug: String!, fromHash: String!, toHash: String!): String!
acl(path: String!): [ACLEntry!]!
aclSubjects: ACLSubjects! # Användare + grupper för ACL-picker
aclSubjects: ACLSubjects! # Användare + grupper för ACL-picker (inkl. guest)
ldapBrowse(url: String, baseDN: String, adminUser: String, adminPass: String): LDAPTree!
}

View File

@@ -253,6 +253,33 @@ func (d *DB) RemoveACL(id int64) error {
return err
}
// GetACLsForSubject retrieves all ACL entries for a specific subject (user or group).
func (d *DB) GetACLsForSubject(subjectType string, subjectID int64) ([]ACLEntry, error) {
rows, err := d.sql.Query(`
SELECT id, path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move
FROM acl WHERE subject_type = ? AND subject_id = ?
ORDER BY path
`, subjectType, subjectID)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ACLEntry
for rows.Next() {
var e ACLEntry
if err := rows.Scan(
&e.ID, &e.Path, &e.SubjectType, &e.SubjectID,
&e.CanSearch, &e.CanView, &e.CanRead, &e.CanEdit,
&e.CanCreate, &e.CanDelete, &e.CanMove,
); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
// GetACLsForPath retrieves all ACL definitions for a specific document or folder.
func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) {
rows, err := d.sql.Query(`

View File

@@ -38,11 +38,17 @@ type Query {
# Fetch permissions for a path
acl(path: String!): [ACLEntry!]!
# Fetch all permissions for a specific user or group (admin only).
subjectAcl(subjectType: String!, subjectId: Int!): [ACLEntry!]!
# Returns all users and groups that can be assigned to ACL entries (admin only).
aclSubjects: ACLSubjects!
# Returns "local", "ldap", or "guest" for a given username.
userAuthType(username: String!): String!
# Returns the role of the currently authenticated user ("admin", "user", "guest").
currentUserRole: String!
}
type Mutation {

View File

@@ -212,9 +212,15 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "ldapBrowse"):
s.handleLdapBrowse(w, req, sess)
case strings.Contains(q, "aclSubjects"):
s.handleAclSubjects(w, sess)
case strings.Contains(q, "users"):
s.handleUsers(w, sess)
case strings.Contains(q, "currentUserRole"):
s.handleCurrentUserRole(w, sess)
case strings.Contains(q, "updateStoragePath"):
s.handleUpdateStoragePath(w, req, sess)
@@ -248,6 +254,9 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "aclSubjects"):
s.handleAclSubjects(w, sess)
case strings.Contains(q, "subjectAcl"):
s.handleSubjectAcl(w, req, sess)
case strings.Contains(q, "acl(") || strings.Contains(q, "acl "):
s.handleAcl(w, req, sess)
@@ -709,6 +718,66 @@ func (s *Server) handleUsers(w http.ResponseWriter, sess *auth.Session) {
})
}
func (s *Server) handleSubjectAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
subjectType := strVal(req.Variables, "subjectType")
subjectIDf, ok := req.Variables["subjectId"].(float64)
if !ok || subjectType == "" {
writeGQLError(w, "Missing subjectType or subjectId")
return
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
acls, err := database.GetACLsForSubject(subjectType, int64(subjectIDf))
if err != nil {
writeGQLError(w, err.Error())
return
}
var aclData []map[string]interface{}
for _, a := range acls {
aclData = append(aclData, map[string]interface{}{
"id": a.ID,
"path": a.Path,
"subjectType": a.SubjectType,
"subjectId": a.SubjectID,
"canSearch": a.CanSearch,
"canView": a.CanView,
"canRead": a.CanRead,
"canEdit": a.CanEdit,
"canCreate": a.CanCreate,
"canDelete": a.CanDelete,
"canMove": a.CanMove,
})
}
if aclData == nil {
aclData = []map[string]interface{}{}
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{"subjectAcl": aclData},
})
}
func (s *Server) handleCurrentUserRole(w http.ResponseWriter, sess *auth.Session) {
if sess == nil {
writeGQLError(w, "UNAUTHORIZED")
return
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"currentUserRole": sess.Role,
},
})
}
func (s *Server) handleAclSubjects(w http.ResponseWriter, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")

View File

@@ -2,9 +2,11 @@
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 CreateItemDialog from './CreateItemDialog.vue'
const emit = defineEmits<{ close: [] }>()
const app = useAppStore()
interface DocMeta { slug: string; title: string }
@@ -378,6 +380,7 @@ function isActive(slug: string) {
</button>
<router-link
v-if="app.role === 'admin'"
to="/admin"
class="w-full btn-ghost text-center text-sm py-1.5"
@click="emit('close')"

View File

@@ -16,11 +16,12 @@ export async function gql<T = unknown>(
body: JSON.stringify({ query, variables }),
})
const json = await res.json()
const json = await res.json()
if (json.errors?.length) {
if (json.errors[0].message === 'UNAUTHORIZED') {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('role')
window.location.href = '/'
}
throw new Error(json.errors[0].message)

View File

@@ -7,6 +7,7 @@ export const useAppStore = defineStore('app', () => {
const requiresSetup = ref(false)
const token = ref<string | null>(localStorage.getItem('token'))
const username = ref<string | null>(localStorage.getItem('username'))
const role = ref<string | null>(localStorage.getItem('role'))
async function checkStatus() {
try {
@@ -15,6 +16,15 @@ export const useAppStore = defineStore('app', () => {
} catch {
requiresSetup.value = true
}
if (token.value) {
try {
const data = await gql<{ currentUserRole: string }>(`{ currentUserRole }`)
role.value = data.currentUserRole
localStorage.setItem('role', data.currentUserRole)
} catch {
// Session expired or otherwise invalid — gql.ts will redirect on UNAUTHORIZED
}
}
}
async function login(user: string, password: string) {
@@ -52,6 +62,10 @@ export const useAppStore = defineStore('app', () => {
username.value = user
localStorage.setItem('token', data.login)
localStorage.setItem('username', user)
const roleData = await gql<{ currentUserRole: string }>(`{ currentUserRole }`)
role.value = roleData.currentUserRole
localStorage.setItem('role', roleData.currentUserRole)
}
async function loginAsGuest() {
@@ -61,8 +75,10 @@ export const useAppStore = defineStore('app', () => {
)
token.value = data.login
username.value = 'guest'
role.value = 'guest'
localStorage.setItem('token', data.login)
localStorage.setItem('username', 'guest')
localStorage.setItem('role', 'guest')
}
async function logout() {
@@ -73,10 +89,12 @@ export const useAppStore = defineStore('app', () => {
}
token.value = null
username.value = null
role.value = null
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('role')
}
return { requiresSetup, token, username, checkStatus, login, loginAsGuest, logout }
return { requiresSetup, token, username, role, checkStatus, login, loginAsGuest, logout }
})

View File

@@ -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<{ acl: any[] }>(
`query GetAcl($path: String!) { acl(path: $path) { id path subjectType subjectId canSearch canView canRead canEdit canCreate canDelete canMove } }`,
{ path: aclPath.value.trim() }
const data = await gql<{ subjectAcl: AclEntry[] }>(
`query SubjectAcl($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 }
)
aclEntries.value = data.acl
subjectAclEntries.value = data.subjectAcl
// 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() || aclSubjectId.value === null) {
showStatus('Select a path and a subject.', true)
return
}
async function saveEntryPerms(entry: AclEntry) {
if (!selectedSubject.value) return
savingEntry.value = entry.id
try {
await gql(
`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`,
{ input: { path: aclPath.value.trim(), subjectType: aclSubjectType.value, subjectId: aclSubjectId.value, ...aclPerms.value } }
{ input: { path: entry.path, subjectType: selectedSubject.value.type, subjectId: selectedSubject.value.id, ...editedPerms.value[entry.id] } }
)
showStatus('Permission saved.')
await loadAcl()
await loadSubjectAcl()
} 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 })
aclEntries.value = aclEntries.value.filter(e => e.id !== id)
subjectAclEntries.value = subjectAclEntries.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 groups. 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">
Select a user or group to view and edit their permissions. 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 entries -->
<div v-if="aclEntries.length" class="mb-6">
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-300 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-500">
<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>
<!-- Groups -->
<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="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-300">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/update 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="aclSubjectType" class="input" @change="aclSubjectId = null">
<option value="user">User</option>
<option value="group">Group</option>
</select>
<!-- Detail panel for selected 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="selectedSubject" 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>
<option 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 permissions 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>