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

@@ -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>