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:
126
ARCHITECTURE.md
126
ARCHITECTURE.md
@@ -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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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{}{
|
||||||
|
|||||||
@@ -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 }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user