feat: add LDAP support and guest user functionality

- Introduced BaseDN configuration for LDAP in the config.
- Enhanced User model to include IsLDAP field.
- Updated database queries to handle LDAP users.
- Implemented GraphQL queries for LDAP browsing and user authentication type.
- Added ACL management for users and groups, including guest user permissions.
- Updated frontend to support LDAP login and guest user login.
- Improved error handling and user feedback in login and ACL management.
This commit is contained in:
2026-04-15 00:42:40 +02:00
parent 61f33d350a
commit d571f156a6
9 changed files with 710 additions and 93 deletions

View File

@@ -139,18 +139,56 @@ Backenden använder **inte** gqlgen-genererad kod i produktion. `server.go` impl
### Autentisering och sessioner ### Autentisering och sessioner
``` ```
Login-flöde: Login-flöde (lokalt konto):
1. Klient: SHA-256(username:password) → skickas som "password" 1. Frontend: kontrollerar authType (query userAuthType) → "local"
2. Server: bcrypt.CompareHashAndPassword(stored_hash, received_digest) 2. Frontend: SHA-256(username:password) → skickas som "password"
3. Vid match: slumpmässig hex-token genereras och sparas i minnet 3. Backend: bcrypt.CompareHashAndPassword(stored_hash, received_digest)
4. Token returneras till klient, lagras i localStorage 4. Vid match: slumpmässig hex-token genereras och sparas i minnet
5. Alla efterföljande requests: Authorization: Bearer <token> 5. Token returneras till klient, lagras i localStorage
Login-flöde (LDAP-konto):
1. Frontend: kontrollerar authType → "ldap"
2. Frontend: skickar klartextlösenord i fältet "ldapPassword" (EJ hashat)
3. Backend: hämtar användarens DN via LDAP-sökning (baseDN + filter)
4. Backend: binder till LDAP med DN + klartextlösenord (ldapBind)
5. Vid lyckad bind: session skapas med roll "user"
Login-flöde (gäst):
1. Frontend: klickar "Continue as Guest" → skickar username="guest", password=""
2. Backend: identifierar gäst, skapar session utan lösenordskontroll, roll "guest"
3. Gäst ser bara dokument/mappar som admin explicit gett tillåtelse via ACL
Session-storage: in-memory map[token]*Session (försvinner vid omstart) Session-storage: in-memory map[token]*Session (försvinner vid omstart)
Session-livstid: 7 dagar (konfigurerat i auth.go) Session-livstid: 24 timmar (konfigurerat i auth.go)
``` ```
**Viktigt:** Sessions lagras enbart i minnet. Vid server-omstart måste alla klienter logga in igen. **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.
### Gästanvändare
En inbyggd `guest`-användare skapas automatiskt vid installation och vid serverstart (migrations-safe). Gästen:
- Kräver inget lösenord
- Har roll `"guest"` (skild från `"user"` och `"admin"`)
- Kan som standard inte se något
- Admin ger tillgång per dokument eller mapp via ACL-panelen
### Åtkomstkontroll (ACL)
ACL-poster lagras i SQLite-tabellen `acl`. Varje post kopplar en sökväg (slug eller mappnamn) till ett subjekt (användare eller grupp) och sju boolean-flaggor:
| Flagga | Innebär |
|------------|----------------------------------------|
| canSearch | Syns i sökning/listning |
| canView | Syns i mapplistning |
| canRead | Kan öppna och läsa innehållet |
| canEdit | Kan redigera och spara |
| canCreate | Kan skapa nya dokument i mappen |
| canDelete | Kan radera |
| canMove | Kan flytta/byta namn |
Sökvägar är hierarkiska: om admin ger gästen tillgång till mappen `docs` räcker det för alla dokument under `docs/`.
Enforcement sker i `handleDocuments` och `handleFolders` — gästanvändare filtreras mot ACL. Admin-användare kringgår alltid ACL-kontroller.
### Setup-detektion — `needsSetup` ### Setup-detektion — `needsSetup`
@@ -179,7 +217,11 @@ resolveDBPath(configPath):
- `db_path` — sökväg till SQLite-fil - `db_path` — sökväg till SQLite-fil
- `jwt_secret` — används för session-token generation (ej JWT i klassisk mening) - `jwt_secret` — används för session-token generation (ej JWT i klassisk mening)
- `listen_addr` — TCP-adress att lyssna på, t.ex. `:4000` - `listen_addr` — TCP-adress att lyssna på, t.ex. `:4000`
- `ldap` — valfri LDAP-konfiguration - `ldap` — valfri LDAP-konfiguration:
- `url` — LDAP-server URL, t.ex. `ldap://openldap:389`
- `base_dn` — rot-DN att söka ifrån, t.ex. `dc=example,dc=com`
- `admin_user` — tjänstekonto DN för uppslag (valfritt)
- `admin_pass` — tjänstekontoets lösenord (valfritt)
Om filen saknas returnerar `Load()` `ErrRequireSetup` — backenden startar i setup-läge. Om filen saknas returnerar `Load()` `ErrRequireSetup` — backenden startar i setup-läge.
@@ -238,28 +280,37 @@ Ordningen är viktig: om DB-skapandet eller user-insert misslyckas sparas **inge
### 4.3 Inloggningsflöde ### 4.3 Inloggningsflöde
``` ```
Användaren fyller i användarnamn + lösenord Användaren fyller i användarnamn + lösenord (eller klickar "Continue as Guest")
app.login(username, password) app.login(username, password)
├─ SHA-256(lowercase(username):password) → hashed ├─ query userAuthType(username) → "local" | "ldap" | "guest"
POST /graphql mutation Login($u, $p: hashed) authType == "local":
│ SHA-256(lowercase(username):password) → hashed
│ → POST /graphql mutation Login(u, p: hashed)
├─ authType == "ldap":
│ → POST /graphql mutation Login(u, p: "", ldapPassword: plaintext)
└─ authType == "guest":
→ POST /graphql mutation Login(u: "guest", p: "")
handleLogin (server.go) handleLogin (server.go)
├─ db.GetUser(username) → lokal SQLite ├─ "guest": returnerar token utan lösenordskontroll
│ ├─ hittad: bcrypt.Compare(stored, hashed) → OK/fel
│ └─ ej hittad + LDAP konfigurerat → ldapBind test
├─ Skapar slumpmässig session-token ├─ "ldap" (is_ldap=1 i DB): ldapBind(username, ldapPassword)
└─ Returnerar token │ → OK: session med roll "user"
└─ "local" (is_ldap=0): bcrypt.Compare(stored, hashed)
Klient sparar token i localStorage → OK: session med roll baserad på DB-värde
AppLayout renderas
Gäst-session:
- Roll "guest" — se ACL-enforcement i handleDocuments / handleFolders
- Kan bara se dokument/mappar som admin explicit beviljat via ACL
``` ```
### 4.4 Spara dokument ### 4.4 Spara dokument
@@ -366,30 +417,37 @@ Archivum/
**GraphQL-schema (urval):** **GraphQL-schema (urval):**
```graphql ```graphql
type Query { type Query {
systemStatus: SystemStatus! # OK | REQUIRE_SETUP systemStatus: SystemStatus!
serverDirectories(path: String): [ServerDirectory!]! # Listar mappar (inkl. isGitRepo) userAuthType(username: String!): String! # "local" | "ldap" | "guest"
documents(prefix: String): [DocumentMeta!]! documents(prefix: String): [DocumentMeta!]!
document(slug: String!): Document document(slug: String!): Document
folders: [String!]! # Listar alla mappvägar i repositoriet 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!]!
aclSubjects: ACLSubjects! # Användare + grupper för ACL-picker
type ServerDirectory { ldapBrowse(url: String, baseDN: String, adminUser: String, adminPass: String): LDAPTree!
name: String!
path: String!
isGitRepo: Boolean!
hasDocuments: Boolean!
} }
type Mutation { type Mutation {
setup(input: SetupInput!): Boolean! login(username: String!, password: String!, ldapPassword: String): String!
login(username: String!, password: String!): String! # returnerar session-token
logout: Boolean! logout: Boolean!
setup(input: SetupInput!): Boolean!
saveDocument(input: SaveDocumentInput!): Document! saveDocument(input: SaveDocumentInput!): Document!
deleteDocument(slug: String!): Boolean! deleteDocument(slug: String!): Boolean!
createFolder(path: String!): Boolean! # Skapar en ny mapp (katalog) updateLdapConfig(input: LDAPInput): Boolean!
moveDocument(oldSlug: String!, newSlug: String!): Boolean! # Flyttar ett dokument till en annan plats importLdapSubject(type: String!, name: String!): Boolean!
setAcl(input: ACLInput!): Boolean!
removeAcl(id: Int!): Boolean!
createFolder(path: String!): Boolean!
moveDocument(oldSlug: String!, newSlug: String!): Boolean!
}
input LDAPInput {
url: String!
baseDN: String
adminUser: String!
adminPass: String # Utelämnas → befintligt lösenord behålls
} }
``` ```

View File

@@ -57,18 +57,49 @@ func CheckPassword(hashedPassword, password string) error {
// Login authenticates via local DB first, then falls back to LDAP if configured. // Login authenticates via local DB first, then falls back to LDAP if configured.
// Returns the session token, the user's role, and any error. // Returns the session token, the user's role, and any error.
func (m *Manager) Login(database *db.DB, username, password string) (token, role string, err error) { // password is a SHA-256 hash (for local accounts); ldapPassword is plaintext (for LDAP bind).
// Try local account first. func (m *Manager) Login(database *db.DB, username, password, ldapPassword string) (token, role string, err error) {
user, dbErr := database.GetUser(username) // Guest login — no password required.
if dbErr == nil { if username == "guest" {
if bcrypt.CompareHashAndPassword([]byte(user.PassHash), []byte(password)) != nil { tok, err := m.createSession("guest", "guest")
return "", "", errors.New("invalid credentials") return tok, "guest", err
}
tok, err := m.createSession(username, user.Role)
return tok, user.Role, err
} }
// If not found locally and LDAP is configured, try LDAP. user, dbErr := database.GetUser(username)
if dbErr == nil {
// If the account has a local password hash, always authenticate locally.
// This ensures admin accounts are never accidentally routed through LDAP,
// even if they were also imported from the directory.
if user.PassHash != "" {
if bcrypt.CompareHashAndPassword([]byte(user.PassHash), []byte(password)) != nil {
return "", "", errors.New("invalid credentials")
}
tok, err := m.createSession(username, user.Role)
return tok, user.Role, err
}
// No local password — must be an LDAP-only account.
if user.IsLDAP {
m.mu.RLock()
cfg := m.cfg
m.mu.RUnlock()
if cfg == nil || cfg.LDAP.Url == "" {
return "", "", errors.New("LDAP not configured")
}
ldapPwd := ldapPassword
if ldapPwd == "" {
ldapPwd = password
}
if err := ldapUserBind(cfg, username, ldapPwd); err != nil {
return "", "", errors.New("invalid credentials")
}
tok, err := m.createSession(username, user.Role)
return tok, user.Role, err
}
// In DB but no password and not LDAP — refuse.
return "", "", errors.New("invalid credentials")
}
// User not in DB. If LDAP is configured try a bind with the provided password.
m.mu.RLock() m.mu.RLock()
cfg := m.cfg cfg := m.cfg
m.mu.RUnlock() m.mu.RUnlock()
@@ -77,7 +108,11 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role
return "", "", errors.New("invalid credentials") return "", "", errors.New("invalid credentials")
} }
if err := ldapUserBind(cfg, username, password); err != nil { ldapPwd := ldapPassword
if ldapPwd == "" {
ldapPwd = password
}
if err := ldapUserBind(cfg, username, ldapPwd); err != nil {
return "", "", err return "", "", err
} }
@@ -85,6 +120,27 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role
return tok, "user", err return tok, "user", err
} }
// UserAuthType returns "guest", "ldap", or "local" for the given username.
// A user with a non-empty pass_hash is always "local", regardless of is_ldap,
// so that the frontend hashes the password before sending it.
func UserAuthType(database *db.DB, username string) string {
if username == "guest" {
return "guest"
}
user, err := database.GetUser(username)
if err != nil {
// User not found in DB — assume LDAP so the frontend sends plaintext.
return "ldap"
}
if user.PassHash != "" {
return "local"
}
if user.IsLDAP {
return "ldap"
}
return "local"
}
// TestLDAP tests an LDAP configuration by performing a service-account bind. // TestLDAP tests an LDAP configuration by performing a service-account bind.
func TestLDAP(url string, adminUser, adminPassword string) error { func TestLDAP(url string, adminUser, adminPassword string) error {
l, err := ldap.DialURL(url) l, err := ldap.DialURL(url)
@@ -111,10 +167,14 @@ func ldapUserBind(cfg *config.Config, username, password string) error {
} }
} }
baseDN := cfg.LDAP.BaseDN
escaped := ldap.EscapeFilter(username)
// Include cn so OpenLDAP entries of the form cn=username are matched too.
filter := fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s)(cn=%s))", escaped, escaped, escaped)
searchRequest := ldap.NewSearchRequest( searchRequest := ldap.NewSearchRequest(
"", // Search from root or a specific BaseDN if needed. Assuming root here since URL can contain it, or we rely on LDAP configured properly baseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s))", ldap.EscapeFilter(username), ldap.EscapeFilter(username)), filter,
[]string{"dn"}, []string{"dn"},
nil, nil,
) )
@@ -132,7 +192,7 @@ func ldapUserBind(cfg *config.Config, username, password string) error {
} }
// BrowseLDAP returns users and groups from the LDAP server. // BrowseLDAP returns users and groups from the LDAP server.
func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string, error) { func BrowseLDAP(url, baseDN, adminUser, adminPassword string) ([]string, []string, error) {
l, err := ldap.DialURL(url) l, err := ldap.DialURL(url)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -145,10 +205,11 @@ func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string
} }
} }
// Search for Users // Search for Users — cover inetOrgPerson (OpenLDAP), person, user (AD), posixAccount
userReq := ldap.NewSearchRequest( userReq := ldap.NewSearchRequest(
"", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, baseDN,
"(|(objectClass=person)(objectClass=user))", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(|(objectClass=inetOrgPerson)(objectClass=person)(objectClass=user)(objectClass=posixAccount))",
[]string{"uid", "sAMAccountName", "cn"}, []string{"uid", "sAMAccountName", "cn"},
nil, nil,
) )
@@ -165,10 +226,11 @@ func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string
} }
} }
// Search for Groups // Search for Groups — cover groupOfNames, group (AD), posixGroup, groupOfUniqueNames
groupReq := ldap.NewSearchRequest( groupReq := ldap.NewSearchRequest(
"", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, baseDN,
"(|(objectClass=groupOfNames)(objectClass=group))", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
"(|(objectClass=groupOfNames)(objectClass=group)(objectClass=posixGroup)(objectClass=groupOfUniqueNames))",
[]string{"cn"}, []string{"cn"},
nil, nil,
) )

View File

@@ -17,6 +17,7 @@ type Config struct {
type LDAPConfig struct { type LDAPConfig struct {
Url string `json:"url"` Url string `json:"url"`
BaseDN string `json:"base_dn"`
AdminUser string `json:"admin_user"` AdminUser string `json:"admin_user"`
AdminPass string `json:"admin_pass"` AdminPass string `json:"admin_pass"`
} }

View File

@@ -18,6 +18,7 @@ type User struct {
Username string Username string
PassHash string PassHash string
Role string Role string
IsLDAP bool
CreatedAt time.Time CreatedAt time.Time
} }
@@ -107,12 +108,12 @@ func (d *DB) CreateUser(username, passHash, role string) error {
// GetUser fetches a single user by username. // GetUser fetches a single user by username.
func (d *DB) GetUser(username string) (*User, error) { func (d *DB) GetUser(username string) (*User, error) {
row := d.sql.QueryRow( row := d.sql.QueryRow(
`SELECT id, username, pass_hash, role, created_at FROM users WHERE username = ?`, `SELECT id, username, pass_hash, role, is_ldap, created_at FROM users WHERE username = ?`,
username, username,
) )
u := &User{} u := &User{}
var createdAt string var createdAt string
err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &createdAt) err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &u.IsLDAP, &createdAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
@@ -126,7 +127,7 @@ func (d *DB) GetUser(username string) (*User, error) {
// ListUsers returns all users ordered by id. // ListUsers returns all users ordered by id.
func (d *DB) ListUsers() ([]*User, error) { func (d *DB) ListUsers() ([]*User, error) {
rows, err := d.sql.Query( rows, err := d.sql.Query(
`SELECT id, username, role, created_at FROM users ORDER BY id`, `SELECT id, username, role, is_ldap, created_at FROM users ORDER BY id`,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -137,7 +138,7 @@ func (d *DB) ListUsers() ([]*User, error) {
for rows.Next() { for rows.Next() {
u := &User{} u := &User{}
var createdAt string var createdAt string
if err := rows.Scan(&u.ID, &u.Username, &u.Role, &createdAt); err != nil { if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.IsLDAP, &createdAt); err != nil {
return nil, err return nil, err
} }
u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
@@ -185,11 +186,14 @@ func (d *DB) UserCount() (int, error) {
// --- LDAP & Roles --- // --- LDAP & Roles ---
// CreateOrUpdateLDAPUser inserts or updates an LDAP user (without password, role='user'). // CreateOrUpdateLDAPUser inserts or updates an LDAP user (without password, role='user').
// Existing accounts with a non-empty pass_hash are never overwritten so that
// a local admin cannot be accidentally converted to an LDAP-only account.
func (d *DB) CreateOrUpdateLDAPUser(username string) error { func (d *DB) CreateOrUpdateLDAPUser(username string) error {
_, err := d.sql.Exec(` _, err := d.sql.Exec(`
INSERT INTO users (username, pass_hash, role, is_ldap) INSERT INTO users (username, pass_hash, role, is_ldap)
VALUES (?, '', 'user', 1) VALUES (?, '', 'user', 1)
ON CONFLICT(username) DO UPDATE SET is_ldap=1; ON CONFLICT(username) DO UPDATE SET is_ldap=1
WHERE excluded.pass_hash = '' AND pass_hash = '';
`, username) `, username)
return err return err
} }
@@ -274,3 +278,89 @@ func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) {
} }
return entries, nil return entries, nil
} }
// Group represents a group stored in SQLite.
type Group struct {
ID int64
Name string
IsLDAP bool
}
// ListGroups returns all groups ordered by name.
func (d *DB) ListGroups() ([]*Group, error) {
rows, err := d.sql.Query(`SELECT id, name, is_ldap FROM groups ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var groups []*Group
for rows.Next() {
g := &Group{}
if err := rows.Scan(&g.ID, &g.Name, &g.IsLDAP); err != nil {
return nil, err
}
groups = append(groups, g)
}
return groups, rows.Err()
}
// EnsureGuestUser creates the built-in guest account if it does not already exist.
func (d *DB) EnsureGuestUser() error {
_, err := d.sql.Exec(`
INSERT INTO users (username, pass_hash, role) VALUES ('guest', '', 'guest')
ON CONFLICT(username) DO NOTHING;
`)
return err
}
// CanUserReadPath returns true if the given username has can_read access on the
// exact path or any of its ancestor folder paths.
func (d *DB) CanUserReadPath(username, path string) bool {
// Build a list of candidate paths: the path itself and each parent segment.
candidates := pathAncestors(path)
for _, p := range candidates {
var n int
d.sql.QueryRow(`
SELECT COUNT(*) FROM acl
JOIN users ON users.id = acl.subject_id AND acl.subject_type = 'user'
WHERE users.username = ? AND acl.path = ? AND acl.can_read = 1
`, username, p).Scan(&n)
if n > 0 {
return true
}
}
return false
}
// CanUserViewPath returns true if the given username has can_view access on the
// exact path or any of its ancestor folder paths.
func (d *DB) CanUserViewPath(username, path string) bool {
candidates := pathAncestors(path)
for _, p := range candidates {
var n int
d.sql.QueryRow(`
SELECT COUNT(*) FROM acl
JOIN users ON users.id = acl.subject_id AND acl.subject_type = 'user'
WHERE users.username = ? AND acl.path = ? AND acl.can_view = 1
`, username, p).Scan(&n)
if n > 0 {
return true
}
}
return false
}
// pathAncestors returns the path and all its parent segments.
// e.g. "a/b/c" → ["a/b/c", "a/b", "a"]
func pathAncestors(path string) []string {
out := []string{path}
for {
idx := strings.LastIndex(path, "/")
if idx < 0 {
break
}
path = path[:idx]
out = append(out, path)
}
return out
}

View File

@@ -33,10 +33,16 @@ type Query {
images(slug: String!): [ImageFile!]! images(slug: String!): [ImageFile!]!
# Browse LDAP tree (admin only) # Browse LDAP tree (admin only)
ldapBrowse(url: String, adminUser: String, adminPass: String): LDAPTree! ldapBrowse(url: String, baseDN: String, adminUser: String, adminPass: String): LDAPTree!
# Fetch permissions for a path # Fetch permissions for a path
acl(path: String!): [ACLEntry!]! acl(path: String!): [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!
} }
type Mutation { type Mutation {
@@ -47,7 +53,7 @@ type Mutation {
testLdapConnection(url: String!, adminUser: String, adminPass: String): LDAPTestResult! testLdapConnection(url: String!, adminUser: String, adminPass: String): LDAPTestResult!
# Authenticate and receive a bearer token. # Authenticate and receive a bearer token.
login(username: String!, password: String!): String! login(username: String!, password: String!, ldapPassword: String): String!
# Invalidate the current session. # Invalidate the current session.
logout: Boolean! logout: Boolean!
@@ -101,6 +107,7 @@ type AppConfig {
type LDAPConfig { type LDAPConfig {
url: String! url: String!
baseDN: String!
adminUser: String! adminUser: String!
} }
@@ -179,8 +186,20 @@ input SetupInput {
input LDAPInput { input LDAPInput {
url: String! url: String!
baseDN: String
adminUser: String! adminUser: String!
adminPass: String! adminPass: String
}
type ACLSubject {
id: Int!
name: String!
isLdap: Boolean!
}
type ACLSubjects {
users: [ACLSubject!]!
groups: [ACLSubject!]!
} }
input ACLInput { input ACLInput {

View File

@@ -75,6 +75,10 @@ func (s *Server) initRuntime(cfg *config.Config) {
log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err) log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err)
} else { } else {
d = database d = database
// Ensure guest user exists (handles upgrades from older versions).
if err := d.EnsureGuestUser(); err != nil {
log.Printf("[db] warning: could not ensure guest user: %v", err)
}
log.Printf("[db] opened at %s", cfg.DBPath) log.Printf("[db] opened at %s", cfg.DBPath)
} }
@@ -157,6 +161,9 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
s.handleTestLdapConnection(w, req) s.handleTestLdapConnection(w, req)
// ── Auth ───────────────────────────────────────────────────────────────── // ── Auth ─────────────────────────────────────────────────────────────────
case strings.Contains(q, "userAuthType"):
s.handleUserAuthType(w, req)
case strings.Contains(q, "login"): case strings.Contains(q, "login"):
s.handleLogin(w, req) s.handleLogin(w, req)
@@ -202,6 +209,9 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "changePassword"): case strings.Contains(q, "changePassword"):
s.handleChangePassword(w, req, sess) s.handleChangePassword(w, req, sess)
case strings.Contains(q, "ldapBrowse"):
s.handleLdapBrowse(w, req, sess)
case strings.Contains(q, "users"): case strings.Contains(q, "users"):
s.handleUsers(w, sess) s.handleUsers(w, sess)
@@ -232,12 +242,12 @@ func (s *Server) dispatchAuthenticated(
case strings.Contains(q, "moveDocument"): case strings.Contains(q, "moveDocument"):
s.handleMoveDocument(w, req, sess) s.handleMoveDocument(w, req, sess)
case strings.Contains(q, "ldapBrowse"):
s.handleLdapBrowse(w, req, sess)
case strings.Contains(q, "importLdapSubject"): case strings.Contains(q, "importLdapSubject"):
s.handleImportLdapSubject(w, req, sess) s.handleImportLdapSubject(w, req, sess)
case strings.Contains(q, "aclSubjects"):
s.handleAclSubjects(w, 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)
@@ -404,6 +414,17 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *au
log.Printf("[document] user %q is opening document %q", sess.Username, slug) log.Printf("[document] user %q is opening document %q", sess.Username, slug)
// Guest users may only read documents they have been given access to.
s.mu.RLock()
database := s.database
s.mu.RUnlock()
if sess.Role == "guest" && database != nil {
if !database.CanUserReadPath(sess.Username, slug) && !database.CanUserViewPath(sess.Username, slug) {
writeGQLError(w, "UNAUTHORIZED")
return
}
}
content, err := store.Read(slug) content, err := store.Read(slug)
if err != nil { if err != nil {
log.Printf("[storage] read %q: %v", slug, err) log.Printf("[storage] read %q: %v", slug, err)
@@ -437,8 +458,18 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *au
log.Printf("[document] user %q listed documents (found %d documents)", sess.Username, len(slugs)) log.Printf("[document] user %q listed documents (found %d documents)", sess.Username, len(slugs))
s.mu.RLock()
database := s.database
s.mu.RUnlock()
docs := make([]map[string]string, 0, len(slugs)) docs := make([]map[string]string, 0, len(slugs))
for _, slug := range slugs { for _, slug := range slugs {
// Guest users may only see documents they have explicit read or view access to.
if sess.Role == "guest" && database != nil {
if !database.CanUserReadPath(sess.Username, slug) && !database.CanUserViewPath(sess.Username, slug) {
continue
}
}
content, err := store.Read(slug) content, err := store.Read(slug)
title := slug title := slug
if err == nil { if err == nil {
@@ -573,6 +604,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
if password == "" { if password == "" {
password, _ = req.Variables["password"].(string) password, _ = req.Variables["password"].(string)
} }
// ldapPassword carries the plaintext password sent by the frontend for LDAP users.
ldapPassword, _ := req.Variables["ldapPassword"].(string)
s.mu.RLock() s.mu.RLock()
database := s.database database := s.database
@@ -584,7 +617,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
return return
} }
token, role, err := mgr.Login(database, username, password) token, role, err := mgr.Login(database, username, password, ldapPassword)
if err != nil { if err != nil {
log.Printf("[auth] login failed for %q: %v", username, err) log.Printf("[auth] login failed for %q: %v", username, err)
writeGQLError(w, "invalid credentials") writeGQLError(w, "invalid credentials")
@@ -599,6 +632,27 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
}) })
} }
func (s *Server) handleUserAuthType(w http.ResponseWriter, req gqlRequest) {
username := strVal(req.Variables, "username")
if username == "" {
username, _ = req.Variables["u"].(string)
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
if database == nil {
writeGQLError(w, "server not initialised")
return
}
authType := auth.UserAuthType(database, username)
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{"userAuthType": authType},
})
}
func (s *Server) handleLogout(w http.ResponseWriter, sess *auth.Session) { func (s *Server) handleLogout(w http.ResponseWriter, sess *auth.Session) {
if sess != nil { if sess != nil {
s.mu.RLock() s.mu.RLock()
@@ -655,6 +709,59 @@ func (s *Server) handleUsers(w http.ResponseWriter, sess *auth.Session) {
}) })
} }
func (s *Server) handleAclSubjects(w http.ResponseWriter, sess *auth.Session) {
if sess == nil || sess.Role != "admin" {
writeGQLError(w, "UNAUTHORIZED")
return
}
s.mu.RLock()
database := s.database
s.mu.RUnlock()
if database == nil {
writeGQLError(w, "database not initialised")
return
}
users, err := database.ListUsers()
if err != nil {
writeGQLError(w, fmt.Sprintf("failed to list users: %v", err))
return
}
groups, err := database.ListGroups()
if err != nil {
writeGQLError(w, fmt.Sprintf("failed to list groups: %v", err))
return
}
userList := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
userList = append(userList, map[string]interface{}{
"id": u.ID,
"name": u.Username,
"isLdap": u.IsLDAP,
})
}
groupList := make([]map[string]interface{}, 0, len(groups))
for _, g := range groups {
groupList = append(groupList, map[string]interface{}{
"id": g.ID,
"name": g.Name,
"isLdap": g.IsLDAP,
})
}
writeJSONObj(w, map[string]interface{}{
"data": map[string]interface{}{
"aclSubjects": map[string]interface{}{
"users": userList,
"groups": groupList,
},
},
})
}
func (s *Server) handleCreateUser(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { func (s *Server) handleCreateUser(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
if sess == nil { if sess == nil {
log.Printf("[unauth] session is nil") log.Printf("[unauth] session is nil")
@@ -814,6 +921,7 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
s.mu.RLock() s.mu.RLock()
cfg := s.cfg cfg := s.cfg
mgr := s.authMgr
s.mu.RUnlock() s.mu.RUnlock()
if cfg == nil { if cfg == nil {
@@ -828,10 +936,15 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
newCfg := *cfg // copy newCfg := *cfg // copy
if hasInput && input != nil { if hasInput && input != nil {
newPass := strVal(input, "adminPass")
if newPass == "" {
newPass = cfg.LDAP.AdminPass // preserve existing password if none provided
}
newCfg.LDAP = config.LDAPConfig{ newCfg.LDAP = config.LDAPConfig{
Url: strVal(input, "url"), Url: strVal(input, "url"),
BaseDN: strVal(input, "baseDN"),
AdminUser: strVal(input, "adminUser"), AdminUser: strVal(input, "adminUser"),
AdminPass: strVal(input, "adminPass"), AdminPass: newPass,
} }
} else { } else {
// Disable LDAP // Disable LDAP
@@ -843,7 +956,15 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
return return
} }
s.initRuntime(&newCfg) // Update the config in-place without reinitialising the runtime.
// This preserves all active sessions while applying the new LDAP settings.
s.mu.Lock()
s.cfg = &newCfg
s.mu.Unlock()
if mgr != nil {
mgr.UpdateConfig(&newCfg)
}
log.Printf("[admin] LDAP config updated by %s", sess.Username) log.Printf("[admin] LDAP config updated by %s", sess.Username)
writeJSON(w, `{"data":{"updateLdapConfig":true}}`) writeJSON(w, `{"data":{"updateLdapConfig":true}}`)
} }
@@ -871,10 +992,11 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
return return
} }
var ldap map[string]interface{} var ldapCfg map[string]interface{}
if cfg.LDAP.Url != "" { if cfg.LDAP.Url != "" {
ldap = map[string]interface{}{ ldapCfg = map[string]interface{}{
"url": cfg.LDAP.Url, "url": cfg.LDAP.Url,
"baseDN": cfg.LDAP.BaseDN,
"adminUser": cfg.LDAP.AdminUser, "adminUser": cfg.LDAP.AdminUser,
} }
} }
@@ -883,7 +1005,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
"data": map[string]interface{}{ "data": map[string]interface{}{
"config": map[string]interface{}{ "config": map[string]interface{}{
"storagePath": cfg.StoragePath, "storagePath": cfg.StoragePath,
"ldap": ldap, "ldap": ldapCfg,
}, },
}, },
}) })
@@ -1043,6 +1165,11 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
return return
} }
// Ensure the built-in guest account exists (no password, role="guest").
if err := database.EnsureGuestUser(); err != nil {
log.Printf("[setup] warning: could not create guest user: %v", err)
}
log.Printf("[setup] admin user %q created", adminUser) log.Printf("[setup] admin user %q created", adminUser)
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil { if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
@@ -1255,18 +1382,20 @@ func (s *Server) handleLdapBrowse(w http.ResponseWriter, req gqlRequest, sess *a
url_ := strVal(req.Variables, "url") url_ := strVal(req.Variables, "url")
adminUser := strVal(req.Variables, "adminUser") adminUser := strVal(req.Variables, "adminUser")
adminPass := strVal(req.Variables, "adminPass") adminPass := strVal(req.Variables, "adminPass")
baseDN := strVal(req.Variables, "baseDN")
if url_ == "" { if url_ == "" {
s.mu.RLock() s.mu.RLock()
if s.cfg != nil && s.cfg.LDAP.Url != "" { if s.cfg != nil && s.cfg.LDAP.Url != "" {
url_ = s.cfg.LDAP.Url url_ = s.cfg.LDAP.Url
baseDN = s.cfg.LDAP.BaseDN
adminUser = s.cfg.LDAP.AdminUser adminUser = s.cfg.LDAP.AdminUser
adminPass = s.cfg.LDAP.AdminPass adminPass = s.cfg.LDAP.AdminPass
} }
s.mu.RUnlock() s.mu.RUnlock()
} }
users, groups, err := auth.BrowseLDAP(url_, adminUser, adminPass) users, groups, err := auth.BrowseLDAP(url_, baseDN, adminUser, adminPass)
if err != nil { if err != nil {
writeGQLError(w, fmt.Sprintf("LDAP traverse error: %v", err)) writeGQLError(w, fmt.Sprintf("LDAP traverse error: %v", err))
return return
@@ -1724,6 +1853,7 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) {
s.mu.RLock() s.mu.RLock()
store := s.store store := s.store
database := s.database
s.mu.RUnlock() s.mu.RUnlock()
if store == nil { if store == nil {
@@ -1738,6 +1868,17 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) {
return return
} }
// Guest users may only see folders they have been granted view access to.
if sess.Role == "guest" && database != nil {
visible := folders[:0]
for _, f := range folders {
if database.CanUserViewPath(sess.Username, f) {
visible = append(visible, f)
}
}
folders = visible
}
log.Printf("[folders] user %q listed folders (found %d folders)", sess.Username, len(folders)) log.Printf("[folders] user %q listed folders (found %d folders)", sess.Username, len(folders))
writeJSONObj(w, map[string]interface{}{ writeJSONObj(w, map[string]interface{}{

View File

@@ -18,10 +18,35 @@ export const useAppStore = defineStore('app', () => {
} }
async function login(user: string, password: string) { async function login(user: string, password: string) {
const hashed = await hashPassword(user, password) // Ask the server whether this is a local, LDAP, or guest account.
let authType = 'local'
try {
const at = await gql<{ userAuthType: string }>(
`query AuthType($u: String!) { userAuthType(username: $u) }`,
{ u: user },
)
authType = at.userAuthType
} catch {
// If the query fails just assume local — login will fail anyway.
}
let hashedPwd = ''
let ldapPwd = ''
if (authType === 'guest') {
// Guest needs no password — send empty strings.
} else if (authType === 'ldap') {
// Send plaintext password so the backend can bind against LDAP.
ldapPwd = password
hashedPwd = '' // not used for LDAP
} else {
// Local account: hash the password before sending.
hashedPwd = await hashPassword(user, password)
}
const data = await gql<{ login: string }>( const data = await gql<{ login: string }>(
`mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`, `mutation Login($u: String!, $p: String!, $lp: String) { login(username: $u, password: $p, ldapPassword: $lp) }`,
{ u: user, p: hashed }, { u: user, p: hashedPwd, lp: ldapPwd || null },
) )
token.value = data.login token.value = data.login
username.value = user username.value = user
@@ -29,6 +54,17 @@ export const useAppStore = defineStore('app', () => {
localStorage.setItem('username', user) localStorage.setItem('username', user)
} }
async function loginAsGuest() {
const data = await gql<{ login: string }>(
`mutation Login($u: String!, $p: String!) { login(username: $u, password: $p) }`,
{ u: 'guest', p: '' },
)
token.value = data.login
username.value = 'guest'
localStorage.setItem('token', data.login)
localStorage.setItem('username', 'guest')
}
async function logout() { async function logout() {
try { try {
await gql(`mutation { logout }`) await gql(`mutation { logout }`)
@@ -41,6 +77,6 @@ export const useAppStore = defineStore('app', () => {
localStorage.removeItem('username') localStorage.removeItem('username')
} }
return { requiresSetup, token, username, checkStatus, login, logout } return { requiresSetup, token, username, checkStatus, login, loginAsGuest, logout }
}) })

View File

@@ -1,13 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, 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'
const storagePath = ref('') const storagePath = ref('')
const ldapEnabled = ref(false) const ldapEnabled = ref(false)
const ldapUrl = ref('') const ldapUrl = ref('')
const ldapBaseDN = ref('')
const ldapAdminUser = ref('') const ldapAdminUser = ref('')
const ldapAdminPassword = ref('') const ldapAdminPassword = ref('')
const ldapPasswordSet = ref(false) // true = server already has a password stored
const oldPass = ref('') const oldPass = ref('')
const newPass = ref('') const newPass = ref('')
@@ -25,23 +27,49 @@ const ldapUsers = ref<string[]>([])
const ldapGroups = ref<string[]>([]) const ldapGroups = ref<string[]>([])
const ldapBrowsing = ref(false) const ldapBrowsing = ref(false)
// ACL Management State
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 () => { onMounted(async () => {
try { try {
const data = await gql<{ config: any }>(`{ config { storagePath ldap { url adminUser } } }`) const data = await gql<{ config: any }>(`{ config { storagePath ldap { url baseDN adminUser } } }`)
storagePath.value = data.config.storagePath || '' storagePath.value = data.config.storagePath || ''
if (data.config.ldap) { if (data.config.ldap) {
ldapEnabled.value = true ldapEnabled.value = true
ldapUrl.value = data.config.ldap.url ldapUrl.value = data.config.ldap.url
ldapBaseDN.value = data.config.ldap.baseDN || ''
ldapAdminUser.value = data.config.ldap.adminUser ldapAdminUser.value = data.config.ldap.adminUser
ldapPasswordSet.value = true // server has a password (not returned for security)
} }
} catch (err: any) { } catch (err: any) {
status.value = { msg: err.message, isError: true } status.value = { msg: err.message, isError: true }
} finally { } finally {
loading.value = false loading.value = false
} }
await loadAclSubjects()
}) })
async function loadAclSubjects() {
try {
const data = await gql<{ aclSubjects: { users: AclSubject[], groups: AclSubject[] } }>(
`{ aclSubjects { users { id name isLdap } groups { id name isLdap } } }`
)
aclSubjectUsers.value = data.aclSubjects.users
aclSubjectGroups.value = data.aclSubjects.groups
} catch {
// non-fatal
}
}
function showStatus(msg: string, isError: boolean = false) { function showStatus(msg: string, isError: boolean = false) {
status.value = { msg, isError } status.value = { msg, isError }
setTimeout(() => status.value.msg = '', 4000) setTimeout(() => status.value.msg = '', 4000)
@@ -50,7 +78,6 @@ function showStatus(msg: string, isError: boolean = false) {
async function saveStorage() { async function saveStorage() {
try { try {
await gql(`mutation UpdateStorage($path: String!) { updateStoragePath(path: $path) }`, { path: storagePath.value }) await gql(`mutation UpdateStorage($path: String!) { updateStoragePath(path: $path) }`, { path: storagePath.value })
// Check if the new storage path has uncommitted files.
const data = await gql<{ repoStatus: { hasUncommitted: boolean } }>(`{ repoStatus { hasUncommitted } }`) const data = await gql<{ repoStatus: { hasUncommitted: boolean } }>(`{ repoStatus { hasUncommitted } }`)
if (data.repoStatus.hasUncommitted) { if (data.repoStatus.hasUncommitted) {
showGitPrompt.value = true showGitPrompt.value = true
@@ -85,12 +112,18 @@ async function saveLdap() {
if (ldapEnabled.value) { if (ldapEnabled.value) {
input = { input = {
url: ldapUrl.value, url: ldapUrl.value,
baseDN: ldapBaseDN.value || null,
adminUser: ldapAdminUser.value, adminUser: ldapAdminUser.value,
adminPass: ldapAdminPassword.value // Only send adminPass if the user typed a new one; empty = keep existing
adminPass: ldapAdminPassword.value || null,
} }
} }
await gql(`mutation UpdateLdap($i: LDAPInput) { updateLdapConfig(input: $i) }`, { i: input }) await gql(`mutation UpdateLdap($i: LDAPInput) { updateLdapConfig(input: $i) }`, { i: input })
if (ldapAdminPassword.value) {
ldapPasswordSet.value = true
ldapAdminPassword.value = ''
}
showStatus(ldapEnabled.value ? 'LDAP config updated.' : 'LDAP disabled.') showStatus(ldapEnabled.value ? 'LDAP config updated.' : 'LDAP disabled.')
} catch (err: any) { } catch (err: any) {
showStatus(err.message, true) showStatus(err.message, true)
@@ -103,7 +136,7 @@ async function testLdap() {
`mutation TestLdap($url: String!, $adminUser: String, $adminPass: String) { `mutation TestLdap($url: String!, $adminUser: String, $adminPass: String) {
testLdapConnection(url: $url, adminUser: $adminUser, adminPass: $adminPass) { success message } testLdapConnection(url: $url, adminUser: $adminUser, adminPass: $adminPass) { success message }
}`, }`,
{ url: ldapUrl.value, adminUser: ldapAdminUser.value, adminPass: ldapAdminPassword.value } { url: ldapUrl.value, adminUser: ldapAdminUser.value, adminPass: ldapAdminPassword.value || null }
) )
if (data.testLdapConnection.success) { if (data.testLdapConnection.success) {
showStatus('LDAP connection successful.', false) showStatus('LDAP connection successful.', false)
@@ -119,14 +152,20 @@ async function browseLdap() {
try { try {
ldapBrowsing.value = true ldapBrowsing.value = true
const data = await gql<{ ldapBrowse: { users: string[], groups: string[] } }>( const data = await gql<{ ldapBrowse: { users: string[], groups: string[] } }>(
`query LdapBrowse($url: String, $adminUser: String, $adminPass: String) { `query LdapBrowse($url: String, $baseDN: String, $adminUser: String, $adminPass: String) {
ldapBrowse(url: $url, adminUser: $adminUser, adminPass: $adminPass) { users groups } ldapBrowse(url: $url, baseDN: $baseDN, adminUser: $adminUser, adminPass: $adminPass) { users groups }
}`, }`,
{ url: ldapUrl.value || undefined, adminUser: ldapAdminUser.value || undefined, adminPass: ldapAdminPassword.value || undefined } {
url: ldapUrl.value || undefined,
baseDN: ldapBaseDN.value || undefined,
adminUser: ldapAdminUser.value || undefined,
adminPass: ldapAdminPassword.value || undefined
}
) )
ldapUsers.value = data.ldapBrowse.users ldapUsers.value = data.ldapBrowse.users
ldapGroups.value = data.ldapBrowse.groups ldapGroups.value = data.ldapBrowse.groups
showStatus('LDAP structure loaded.') showStatus('LDAP structure loaded.')
await loadAclSubjects()
} catch (err: any) { } catch (err: any) {
showStatus(err.message, true) showStatus(err.message, true)
} finally { } finally {
@@ -138,6 +177,7 @@ async function importSubject(type: 'user' | 'group', name: string) {
try { try {
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()
} catch (err: any) { } catch (err: any) {
showStatus(err.message, true) showStatus(err.message, true)
} }
@@ -158,6 +198,57 @@ async function savePassword() {
showStatus(err.message, true) showStatus(err.message, true)
} }
} }
// ── ACL Management ─────────────────────────────────────────────────────────────
const aclSubjects = computed(() =>
aclSubjectType.value === 'user' ? aclSubjectUsers.value : aclSubjectGroups.value
)
async function loadAcl() {
if (!aclPath.value.trim()) { showStatus('Enter a path first.', true); 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() }
)
aclEntries.value = data.acl
} 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
}
try {
await gql(
`mutation SetAcl($input: ACLInput!) { setAcl(input: $input) }`,
{ input: { path: aclPath.value.trim(), subjectType: aclSubjectType.value, subjectId: aclSubjectId.value, ...aclPerms.value } }
)
showStatus('Permission saved.')
await loadAcl()
} catch (err: any) {
showStatus(err.message, true)
}
}
async function removeAcl(id: number) {
try {
await gql(`mutation RemoveAcl($id: Int!) { removeAcl(id: $id) }`, { id })
aclEntries.value = aclEntries.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> </script>
<template> <template>
@@ -186,7 +277,7 @@ async function savePassword() {
</div> </div>
</section> </section>
<!-- Git initial commit prompt (shown after storage path change if repo has uncommitted files) --> <!-- Git initial commit prompt -->
<transition <transition
enter-active-class="transition duration-200 ease-out" enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2" enter-from-class="opacity-0 -translate-y-2"
@@ -266,13 +357,20 @@ async function savePassword() {
<label class="label">LDAP URL</label> <label class="label">LDAP URL</label>
<input v-model="ldapUrl" class="input" placeholder="ldap://ldap.example.com:389" /> <input v-model="ldapUrl" class="input" placeholder="ldap://ldap.example.com:389" />
</div> </div>
<div class="sm:col-span-2">
<label class="label">Base DN</label>
<input v-model="ldapBaseDN" class="input" placeholder="dc=example,dc=com" />
<p class="text-xs text-slate-500 mt-1">The root distinguished name to search from. Leave empty to search from the LDAP root (not recommended).</p>
</div>
<div class="sm:col-span-2 md:col-span-1"> <div class="sm:col-span-2 md:col-span-1">
<label class="label">Admin User (Optional Service Account)</label> <label class="label">Service Account DN (Optional)</label>
<input v-model="ldapAdminUser" class="input" placeholder="cn=reader,dc=example,dc=com" /> <input v-model="ldapAdminUser" class="input" placeholder="cn=reader,dc=example,dc=com" />
</div> </div>
<div class="sm:col-span-2 md:col-span-1"> <div class="sm:col-span-2 md:col-span-1">
<label class="label">Admin Password</label> <label class="label">Service Account Password</label>
<input v-model="ldapAdminPassword" type="password" class="input" /> <input v-model="ldapAdminPassword" type="password" class="input"
:placeholder="ldapPasswordSet ? '••••••• (leave blank to keep)' : 'Password'" />
<p v-if="ldapPasswordSet" class="text-xs text-slate-500 mt-1">A password is already saved. Only fill this in to change it.</p>
</div> </div>
</div> </div>
@@ -316,5 +414,91 @@ async function savePassword() {
</div> </div>
</section> </section>
<!-- 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>).
</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>
<!-- 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>
</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>
</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>
</div>
</div>
<div class="sm:col-span-2 flex justify-end">
<button class="btn-primary" @click="saveAcl">Save Permission</button>
</div>
</div>
</div>
</section>
</div> </div>
</template> </template>

View File

@@ -14,11 +14,19 @@ async function submit() {
try { try {
await app.login(username.value, password.value) await app.login(username.value, password.value)
} catch (err: any) { } catch (err: any) {
if (err instanceof Error) { error.value = err instanceof Error ? err.message : 'Failed to sign in'
error.value = err.message } finally {
} else { loading.value = false
error.value = 'Failed to sign in' }
} }
async function continueAsGuest() {
error.value = ''
loading.value = true
try {
await app.loginAsGuest()
} catch (err: any) {
error.value = err instanceof Error ? err.message : 'Failed to sign in as guest'
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -79,6 +87,24 @@ async function submit() {
{{ loading ? 'Signing in...' : 'Sign in' }} {{ loading ? 'Signing in...' : 'Sign in' }}
</button> </button>
</form> </form>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-slate-200 dark:border-slate-700"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-slate-800 px-2 text-slate-400">or</span>
</div>
</div>
<button
type="button"
class="w-full flex justify-center py-2.5 px-4 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-400 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="continueAsGuest"
>
Continue as Guest
</button>
</div> </div>
</div> </div>
</template> </template>