From d808b0289f121f3259a07dd4595d08cdc0996667 Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Thu, 16 Apr 2026 07:01:57 +0200 Subject: [PATCH] partial implement (Save) --- ARCHITECTURE.md | 6 +- backend/internal/db/db.go | 27 ++ backend/internal/graph/schema.graphql | 6 + backend/internal/graph/server.go | 69 +++++ frontend/src/components/layout/Sidebar.vue | 3 + frontend/src/lib/gql.ts | 3 +- frontend/src/stores/app.ts | 20 +- frontend/src/views/AdminView.vue | 334 ++++++++++++++------- 8 files changed, 363 insertions(+), 105 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 78cfc1d..faa9c66 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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! } diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index b905b88..24e5b6a 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -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(` diff --git a/backend/internal/graph/schema.graphql b/backend/internal/graph/schema.graphql index 003f4e3..cc4ca26 100644 --- a/backend/internal/graph/schema.graphql +++ b/backend/internal/graph/schema.graphql @@ -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 { diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index 2fe920d..aef60c3 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -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") diff --git a/frontend/src/components/layout/Sidebar.vue b/frontend/src/components/layout/Sidebar.vue index be47243..86625df 100644 --- a/frontend/src/components/layout/Sidebar.vue +++ b/frontend/src/components/layout/Sidebar.vue @@ -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) { ( 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) diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 25cb91c..8602b3c 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -7,6 +7,7 @@ export const useAppStore = defineStore('app', () => { const requiresSetup = ref(false) const token = ref(localStorage.getItem('token')) const username = ref(localStorage.getItem('username')) + const role = ref(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 } }) diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index ee7146b..348efa1 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,5 +1,5 @@