partial implement (Save)
This commit is contained in:
@@ -160,6 +160,9 @@ Login-flöde (gäst):
|
|||||||
|
|
||||||
Session-storage: in-memory map[token]*Session (försvinner vid omstart)
|
Session-storage: in-memory map[token]*Session (försvinner vid omstart)
|
||||||
Session-livstid: 24 timmar (konfigurerat i auth.go)
|
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.
|
**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 {
|
type Query {
|
||||||
systemStatus: SystemStatus!
|
systemStatus: SystemStatus!
|
||||||
userAuthType(username: String!): String! # "local" | "ldap" | "guest"
|
userAuthType(username: String!): String! # "local" | "ldap" | "guest"
|
||||||
|
currentUserRole: String! # Returnerar rollen för inloggad session ("admin" | "user" | "guest")
|
||||||
documents(prefix: String): [DocumentMeta!]!
|
documents(prefix: String): [DocumentMeta!]!
|
||||||
document(slug: String!): Document
|
document(slug: String!): Document
|
||||||
folders: [String!]!
|
folders: [String!]!
|
||||||
history(slug: String!): [CommitEntry!]!
|
history(slug: String!): [CommitEntry!]!
|
||||||
diff(slug: String!, fromHash: String!, toHash: String!): String!
|
diff(slug: String!, fromHash: String!, toHash: String!): String!
|
||||||
acl(path: String!): [ACLEntry!]!
|
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!
|
ldapBrowse(url: String, baseDN: String, adminUser: String, adminPass: String): LDAPTree!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,33 @@ func (d *DB) RemoveACL(id int64) error {
|
|||||||
return err
|
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.
|
// GetACLsForPath retrieves all ACL definitions for a specific document or folder.
|
||||||
func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) {
|
func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) {
|
||||||
rows, err := d.sql.Query(`
|
rows, err := d.sql.Query(`
|
||||||
|
|||||||
@@ -38,11 +38,17 @@ type Query {
|
|||||||
# Fetch permissions for a path
|
# Fetch permissions for a path
|
||||||
acl(path: String!): [ACLEntry!]!
|
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).
|
# Returns all users and groups that can be assigned to ACL entries (admin only).
|
||||||
aclSubjects: ACLSubjects!
|
aclSubjects: ACLSubjects!
|
||||||
|
|
||||||
# Returns "local", "ldap", or "guest" for a given username.
|
# Returns "local", "ldap", or "guest" for a given username.
|
||||||
userAuthType(username: String!): String!
|
userAuthType(username: String!): String!
|
||||||
|
|
||||||
|
# Returns the role of the currently authenticated user ("admin", "user", "guest").
|
||||||
|
currentUserRole: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
|||||||
@@ -212,9 +212,15 @@ func (s *Server) dispatchAuthenticated(
|
|||||||
case strings.Contains(q, "ldapBrowse"):
|
case strings.Contains(q, "ldapBrowse"):
|
||||||
s.handleLdapBrowse(w, req, sess)
|
s.handleLdapBrowse(w, req, sess)
|
||||||
|
|
||||||
|
case strings.Contains(q, "aclSubjects"):
|
||||||
|
s.handleAclSubjects(w, sess)
|
||||||
|
|
||||||
case strings.Contains(q, "users"):
|
case strings.Contains(q, "users"):
|
||||||
s.handleUsers(w, sess)
|
s.handleUsers(w, sess)
|
||||||
|
|
||||||
|
case strings.Contains(q, "currentUserRole"):
|
||||||
|
s.handleCurrentUserRole(w, sess)
|
||||||
|
|
||||||
case strings.Contains(q, "updateStoragePath"):
|
case strings.Contains(q, "updateStoragePath"):
|
||||||
s.handleUpdateStoragePath(w, req, sess)
|
s.handleUpdateStoragePath(w, req, sess)
|
||||||
|
|
||||||
@@ -248,6 +254,9 @@ func (s *Server) dispatchAuthenticated(
|
|||||||
case strings.Contains(q, "aclSubjects"):
|
case strings.Contains(q, "aclSubjects"):
|
||||||
s.handleAclSubjects(w, sess)
|
s.handleAclSubjects(w, sess)
|
||||||
|
|
||||||
|
case strings.Contains(q, "subjectAcl"):
|
||||||
|
s.handleSubjectAcl(w, req, sess)
|
||||||
|
|
||||||
case strings.Contains(q, "acl(") || strings.Contains(q, "acl "):
|
case strings.Contains(q, "acl(") || strings.Contains(q, "acl "):
|
||||||
s.handleAcl(w, req, sess)
|
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) {
|
func (s *Server) handleAclSubjects(w http.ResponseWriter, sess *auth.Session) {
|
||||||
if sess == nil || sess.Role != "admin" {
|
if sess == nil || sess.Role != "admin" {
|
||||||
writeGQLError(w, "UNAUTHORIZED")
|
writeGQLError(w, "UNAUTHORIZED")
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { gql, createFolder, moveDocument } from '@/lib/gql'
|
import { gql, createFolder, moveDocument } from '@/lib/gql'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
import CreateItemDialog from './CreateItemDialog.vue'
|
import CreateItemDialog from './CreateItemDialog.vue'
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
const app = useAppStore()
|
||||||
|
|
||||||
interface DocMeta { slug: string; title: string }
|
interface DocMeta { slug: string; title: string }
|
||||||
|
|
||||||
@@ -378,6 +380,7 @@ function isActive(slug: string) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
|
v-if="app.role === 'admin'"
|
||||||
to="/admin"
|
to="/admin"
|
||||||
class="w-full btn-ghost text-center text-sm py-1.5"
|
class="w-full btn-ghost text-center text-sm py-1.5"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ export async function gql<T = unknown>(
|
|||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (json.errors?.length) {
|
if (json.errors?.length) {
|
||||||
if (json.errors[0].message === 'UNAUTHORIZED') {
|
if (json.errors[0].message === 'UNAUTHORIZED') {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('username')
|
localStorage.removeItem('username')
|
||||||
|
localStorage.removeItem('role')
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
throw new Error(json.errors[0].message)
|
throw new Error(json.errors[0].message)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
const requiresSetup = ref(false)
|
const requiresSetup = ref(false)
|
||||||
const token = ref<string | null>(localStorage.getItem('token'))
|
const token = ref<string | null>(localStorage.getItem('token'))
|
||||||
const username = ref<string | null>(localStorage.getItem('username'))
|
const username = ref<string | null>(localStorage.getItem('username'))
|
||||||
|
const role = ref<string | null>(localStorage.getItem('role'))
|
||||||
|
|
||||||
async function checkStatus() {
|
async function checkStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -15,6 +16,15 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
} catch {
|
} catch {
|
||||||
requiresSetup.value = true
|
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) {
|
async function login(user: string, password: string) {
|
||||||
@@ -52,6 +62,10 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
username.value = user
|
username.value = user
|
||||||
localStorage.setItem('token', data.login)
|
localStorage.setItem('token', data.login)
|
||||||
localStorage.setItem('username', user)
|
localStorage.setItem('username', user)
|
||||||
|
|
||||||
|
const roleData = await gql<{ currentUserRole: string }>(`{ currentUserRole }`)
|
||||||
|
role.value = roleData.currentUserRole
|
||||||
|
localStorage.setItem('role', roleData.currentUserRole)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginAsGuest() {
|
async function loginAsGuest() {
|
||||||
@@ -61,8 +75,10 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
)
|
)
|
||||||
token.value = data.login
|
token.value = data.login
|
||||||
username.value = 'guest'
|
username.value = 'guest'
|
||||||
|
role.value = 'guest'
|
||||||
localStorage.setItem('token', data.login)
|
localStorage.setItem('token', data.login)
|
||||||
localStorage.setItem('username', 'guest')
|
localStorage.setItem('username', 'guest')
|
||||||
|
localStorage.setItem('role', 'guest')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
@@ -73,10 +89,12 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
}
|
}
|
||||||
token.value = null
|
token.value = null
|
||||||
username.value = null
|
username.value = null
|
||||||
|
role.value = null
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('username')
|
localStorage.removeItem('username')
|
||||||
|
localStorage.removeItem('role')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { requiresSetup, token, username, checkStatus, login, loginAsGuest, logout }
|
return { requiresSetup, token, username, role, checkStatus, login, loginAsGuest, logout }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { gql } from '@/lib/gql'
|
import { gql } from '@/lib/gql'
|
||||||
import FolderPicker from '@/components/FolderPicker.vue'
|
import FolderPicker from '@/components/FolderPicker.vue'
|
||||||
|
|
||||||
@@ -31,11 +31,6 @@ const ldapBrowsing = ref(false)
|
|||||||
type AclSubject = { id: number; name: string; isLdap: boolean }
|
type AclSubject = { id: number; name: string; isLdap: boolean }
|
||||||
const aclSubjectUsers = ref<AclSubject[]>([])
|
const aclSubjectUsers = ref<AclSubject[]>([])
|
||||||
const aclSubjectGroups = 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 () => {
|
onMounted(async () => {
|
||||||
try {
|
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 })
|
await gql(`mutation ImportSubj($type: String!, $name: String!) { importLdapSubject(type: $type, name: $name) }`, { type, name })
|
||||||
showStatus(`Imported ${type} ${name} successfully.`)
|
showStatus(`Imported ${type} ${name} successfully.`)
|
||||||
await loadAclSubjects()
|
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) {
|
} catch (err: any) {
|
||||||
showStatus(err.message, true)
|
showStatus(err.message, true)
|
||||||
}
|
}
|
||||||
@@ -201,54 +200,109 @@ async function savePassword() {
|
|||||||
|
|
||||||
// ── ACL Management ─────────────────────────────────────────────────────────────
|
// ── ACL Management ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const aclSubjects = computed(() =>
|
interface AclEntry {
|
||||||
aclSubjectType.value === 'user' ? aclSubjectUsers.value : aclSubjectGroups.value
|
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() {
|
type PermKey = 'canSearch' | 'canView' | 'canRead' | 'canEdit' | 'canCreate' | 'canDelete' | 'canMove'
|
||||||
if (!aclPath.value.trim()) { showStatus('Enter a path first.', true); return }
|
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 {
|
try {
|
||||||
const data = await gql<{ acl: any[] }>(
|
const data = await gql<{ subjectAcl: AclEntry[] }>(
|
||||||
`query GetAcl($path: String!) { acl(path: $path) { id path subjectType subjectId canSearch canView canRead canEdit canCreate canDelete canMove } }`,
|
`query SubjectAcl($t: String!, $id: Int!) { subjectAcl(subjectType: $t, subjectId: $id) { id path subjectType subjectId canSearch canView canRead canEdit canCreate canDelete canMove } }`,
|
||||||
{ path: aclPath.value.trim() }
|
{ 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) {
|
} catch (err: any) {
|
||||||
showStatus(err.message, true)
|
showStatus(err.message, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAcl() {
|
async function saveEntryPerms(entry: AclEntry) {
|
||||||
if (!aclPath.value.trim() || aclSubjectId.value === null) {
|
if (!selectedSubject.value) return
|
||||||
showStatus('Select a path and a subject.', true)
|
savingEntry.value = entry.id
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await gql(
|
await gql(
|
||||||
`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`,
|
`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.')
|
showStatus('Permission saved.')
|
||||||
await loadAcl()
|
await loadSubjectAcl()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showStatus(err.message, true)
|
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 {
|
try {
|
||||||
await gql(`mutation RemoveAcl($id: Int!) { removeAcl(id: $id) }`, { id })
|
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.')
|
showStatus('Permission removed.')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
showStatus(err.message, true)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -396,7 +450,7 @@ function subjectName(type: string, id: number): string {
|
|||||||
<ul class="max-h-48 overflow-y-auto space-y-1">
|
<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">
|
<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>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -405,7 +459,7 @@ function subjectName(type: string, id: number): string {
|
|||||||
<ul class="max-h-48 overflow-y-auto space-y-1">
|
<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">
|
<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>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,88 +470,164 @@ function subjectName(type: string, id: number): string {
|
|||||||
|
|
||||||
<!-- ACL Management -->
|
<!-- ACL Management -->
|
||||||
<section class="card p-6">
|
<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>
|
<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-4">
|
<p class="text-sm text-slate-500 dark:text-slate-400 mb-5">
|
||||||
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>).
|
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>
|
</p>
|
||||||
|
|
||||||
<!-- Path lookup -->
|
<!-- Subject lists -->
|
||||||
<div class="flex gap-2 mb-6">
|
<div class="grid sm:grid-cols-2 gap-4 mb-6">
|
||||||
<input v-model="aclPath" class="input flex-1" placeholder="docs/intro or docs" @keydown.enter="loadAcl" />
|
<!-- Users -->
|
||||||
<button class="btn-secondary" @click="loadAcl">Load ACL</button>
|
<div class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
</div>
|
<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 -->
|
<!-- Groups -->
|
||||||
<div v-if="aclEntries.length" class="mb-6">
|
<div class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
<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="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">
|
||||||
<div class="overflow-x-auto">
|
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<table class="w-full text-xs border-collapse">
|
<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"/>
|
||||||
<thead>
|
</svg>
|
||||||
<tr class="text-left text-slate-500">
|
<span class="text-sm font-semibold text-slate-700 dark:text-slate-300">Groups</span>
|
||||||
<th class="pb-1 pr-3">Subject</th>
|
<span class="ml-auto text-xs text-slate-400">{{ aclSubjectGroups.length }}</span>
|
||||||
<th class="pb-1 pr-2">Search</th>
|
</div>
|
||||||
<th class="pb-1 pr-2">View</th>
|
<ul class="max-h-52 overflow-y-auto divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||||
<th class="pb-1 pr-2">Read</th>
|
<li v-if="aclSubjectGroups.length === 0" class="px-3 py-4 text-sm text-slate-400 text-center italic">No groups</li>
|
||||||
<th class="pb-1 pr-2">Edit</th>
|
<li
|
||||||
<th class="pb-1 pr-2">Create</th>
|
v-for="g in aclSubjectGroups" :key="g.id"
|
||||||
<th class="pb-1 pr-2">Delete</th>
|
:class="[
|
||||||
<th class="pb-1 pr-2">Move</th>
|
'flex items-center gap-2 px-3 py-2 cursor-pointer text-sm transition',
|
||||||
<th class="pb-1"></th>
|
selectedSubject?.type === 'group' && selectedSubject?.id === g.id
|
||||||
</tr>
|
? 'bg-accent-500/10 text-accent-700 dark:text-accent-400 font-medium'
|
||||||
</thead>
|
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/60'
|
||||||
<tbody>
|
]"
|
||||||
<tr v-for="e in aclEntries" :key="e.id" class="border-t border-slate-100 dark:border-slate-700">
|
@click="selectSubject('group', g.id, g.name, g.isLdap)"
|
||||||
<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>
|
<span class="truncate flex-1">{{ g.name }}</span>
|
||||||
<td class="py-1 pr-2 text-center">{{ e.canView ? '✓' : '–' }}</td>
|
<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>
|
||||||
<td class="py-1 pr-2 text-center">{{ e.canRead ? '✓' : '–' }}</td>
|
</li>
|
||||||
<td class="py-1 pr-2 text-center">{{ e.canEdit ? '✓' : '–' }}</td>
|
</ul>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add/update entry -->
|
<!-- Detail panel for selected subject -->
|
||||||
<div class="border-t border-slate-200 dark:border-slate-700 pt-4">
|
<transition
|
||||||
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Add / update permission</h3>
|
enter-active-class="transition duration-150 ease-out"
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
enter-from-class="opacity-0 translate-y-1"
|
||||||
<div>
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
<label class="label">Type</label>
|
>
|
||||||
<select v-model="aclSubjectType" class="input" @change="aclSubjectId = null">
|
<div v-if="selectedSubject" class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
<option value="user">User</option>
|
<!-- Detail header -->
|
||||||
<option value="group">Group</option>
|
<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">
|
||||||
</select>
|
<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>
|
||||||
<div>
|
|
||||||
<label class="label">Subject</label>
|
<!-- Existing entries -->
|
||||||
<select v-model="aclSubjectId" class="input">
|
<div class="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||||
<option :value="null" disabled>— select —</option>
|
<div v-if="subjectAclEntries.length === 0" class="px-4 py-6 text-sm text-slate-400 text-center italic">
|
||||||
<option v-for="s in aclSubjects" :key="s.id" :value="s.id">
|
No permissions set. Add one below.
|
||||||
{{ s.name }}{{ s.isLdap ? ' (LDAP)' : '' }}
|
</div>
|
||||||
</option>
|
<div v-for="entry in subjectAclEntries" :key="entry.id" class="px-4 py-3">
|
||||||
</select>
|
<div class="flex items-start gap-3 flex-wrap">
|
||||||
</div>
|
<!-- Path -->
|
||||||
<div class="sm:col-span-2">
|
<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>
|
||||||
<label class="label">Permissions</label>
|
<!-- Permission checkboxes -->
|
||||||
<div class="flex flex-wrap gap-4 mt-1">
|
<div class="flex flex-wrap gap-x-4 gap-y-1 flex-1 min-w-0">
|
||||||
<label v-for="perm in ['canSearch','canView','canRead','canEdit','canCreate','canDelete','canMove']" :key="perm" class="flex items-center gap-1 text-sm cursor-pointer">
|
<label
|
||||||
<input type="checkbox" v-model="(aclPerms as any)[perm]" class="rounded" />
|
v-for="perm in ALL_PERMS" :key="perm"
|
||||||
{{ perm.replace('can', '') }}
|
class="flex items-center gap-1 text-xs cursor-pointer text-slate-600 dark:text-slate-400"
|
||||||
</label>
|
>
|
||||||
|
<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>
|
</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>
|
</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>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user