diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dd6de64 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +# Keep the build context small and reproducible. The Dockerfile builds the +# frontend from source (npm install + vite) and the backend from Go sources, so +# none of the following belong in the context. +.git +.gitea +.github +.vscode +.idea + +# Frontend build artefacts / deps (rebuilt inside the image) +frontend/node_modules +frontend/dist +frontend/.env +frontend/.env.local + +# Backend local dev artefacts +backend/archivum +backend/*.exe +backend/*.test +backend/vendor + +# Local state, databases and example data (never build into the image) +**/*.db +config.json +exempel.data +exempel.db +docker/data + +# Docs / misc +*.md +*.pdf +LICENSE +repl.txt +backend/chunk.txt +backend/test_db.go diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..824a652 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,35 @@ +name: build-and-push + +# Bygger Archivum-imagen på Pi5-runnern (arm64, nativt) och pushar till den lokala +# registryn. Ett enda multi-stage-bygge (frontend -> backend -> runtime), så +# build-context måste vara repo-roten (Dockerfile COPY:ar frontend/, backend/ och +# docker/entrypoint.sh). localhost:5000 trustas automatiskt av Docker på Pi5 +# (ingen insecure-registry-config behövs där). +# +# Pull:a på Pi5 med localhost:5000/archivum:latest (t.ex. i dockge-stacken). +on: + push: + branches: [main, master] + paths: + - "backend/**" + - "frontend/**" + - "docker/**" + - ".gitea/workflows/build.yaml" + workflow_dispatch: {} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build & push image + run: | + docker build --progress=plain \ + -f docker/Dockerfile \ + -t localhost:5000/archivum:latest \ + -t "localhost:5000/archivum:${GITHUB_SHA::12}" \ + . + docker push localhost:5000/archivum:latest + docker push "localhost:5000/archivum:${GITHUB_SHA::12}" diff --git a/.gitignore b/.gitignore index 0deee54..d88606d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ node_modules/ testdata/ test_data/ tests/data/ + +# Local example data (not part of the app) +exempel.data/ +exempel.db/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index faa9c66..0e5d36e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -45,7 +45,8 @@ Webbläsare | PWA | vite-plugin-pwa (Service Worker, manifest) | | Backend | Go 1.22, HTTP-standardbibliotek | | Databas | SQLite via modernc.org/sqlite (CGO-fri, multi-arch) | -| Autentisering| Lokala konton (bcrypt) + LDAP, JWT-liknande sessions-tokens | +| Autentisering| OIDC/SSO (Authentik) + lokala konton (bcrypt) + LDAP, sessions-tokens i minnet | +| RBAC / ACL | Roll via OIDC-grupper; allow/deny-ACL per sökväg för användare & grupper | | Versionshantering | Git via `os/exec` | | Containerisering | Docker (multi-stage, multi-arch: amd64 + arm64) | @@ -175,9 +176,33 @@ En inbyggd `guest`-användare skapas automatiskt vid installation och vid server - Kan som standard inte se något - Admin ger tillgång per dokument eller mapp via ACL-panelen -### Åtkomstkontroll (ACL) +### Autentisering via OIDC (Authentik) -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: +Archivum är en confidential OIDC-klient mot Authentik. Flödet är rent +browser-redirect och ligger utanför GraphQL (två HTTP-endpoints): + +``` +Login-sida → GET /auth/oidc/login + → 302 till Authentik (authorize, scope: openid profile email groups, state) + → användaren autentiseras (Authentik → OpenLDAP) + → 302 tillbaka GET /auth/oidc/callback?code&state + ├─ validerar state (CSRF), byter code→token (backend↔Authentik, TLS) + ├─ verifierar id_token (JWKS via go-oidc), läser preferred_username + groups + ├─ mappar grupp → roll: Archivum-admin → admin, annars user + ├─ speglar användare + grupper till SQLite (för admin-UI + ACL-mål) + ├─ login-grind: användare/grupp måste vara tillåten + └─ 302 till /oidc/callback#token=… (SPA:n läser token ur fragmentet) +``` + +Konfiguration ligger i `config.oidc` (issuer, client_id/secret, grupp-mappning) +och sätts i admin-panelen. Discovery körs i bakgrunden vid start/ändring så att +en otillgänglig IdP inte blockerar servern. Se `docs/AUTHENTIK_SETUP.md`. + +### Åtkomstkontroll (allow/deny-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), en **effekt** +(`allow`/`deny`) och sju rättighetsflaggor: | Flagga | Innebär | |------------|----------------------------------------| @@ -189,9 +214,25 @@ ACL-poster lagras i SQLite-tabellen `acl`. Varje post kopplar en sökväg (slug | 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/`. +**Effektiv rättighet** (`db.EffectiveAccess`) beräknas för (användare, sökväg, +handling) genom att kombinera **alla** poster som gäller subjektet — användaren +själv **plus alla dess grupper** (från OIDC-claimet eller lokalt medlemskap) — +över sökvägen **och alla dess överliggande mappar**: -Enforcement sker i `handleDocuments` och `handleFolders` — gästanvändare filtreras mot ACL. Admin-användare kringgår alltid ACL-kontroller. +1. Standard = **deny**. +2. En `allow` på handlingen ger tillgång. +3. En `deny` på handlingen **vinner alltid** över allow. + +Alltså: tillgång ges bara om någon post tillåter och ingen post nekar. Sökvägar +är hierarkiska (en `allow` på mappen `docs` gäller allt under `docs/`). + +Enforcement sker för **alla icke-admins** (inloggade användare, `Archivum-reader` +och den publika/gäst-användaren) i alla operationer — listning, läsning, spara, +radera, flytta, skapa, historik/diff och bilduppladdning. **Admin kringgår ACL.** + +> **Känd begränsning:** `/media/` serveras utan ACL eftersom bilder laddas +> via `` utan Authorization-header. Skydda känsliga bilder på annat sätt +> (t.ex. signerade URL:er) om det behövs. ### Setup-detektion — `needsSetup` @@ -220,7 +261,13 @@ resolveDBPath(configPath): - `db_path` — sökväg till SQLite-fil - `jwt_secret` — används för session-token generation (ej JWT i klassisk mening) - `listen_addr` — TCP-adress att lyssna på, t.ex. `:4000` -- `ldap` — valfri LDAP-konfiguration: +- `public_url` — extern bas-URL (t.ex. `https://archivum.brasse-pc.eu`), används + för att härleda OIDC-redirect-URI +- `oidc` — valfri OIDC/SSO-konfiguration (Authentik): + - `enabled`, `issuer`, `client_id`, `client_secret`, `redirect_url` + - `groups_claim` (default `groups`), `username_claim` (default `preferred_username`) + - `admin_group` (default `Archivum-admin`), `reader_group` (default `Archivum-reader`) +- `ldap` — valfri LDAP-konfiguration (direkt bind, äldre alternativ): - `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) diff --git a/README.md b/README.md index 5b10c8a..d59a5fe 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,37 @@ Konfigurationen lagras i `config.json`. Filen skapas automatiskt av installation --- +## Autentisering & behörigheter + +Archivum stöder tre inloggningssätt: + +- **SSO via Authentik (OIDC)** — rekommenderat. Användare loggar in genom + Authentik (som i sin tur autentiserar mot LDAP). Grupp-medlemskap avgör roll + och åtkomst. Se [`docs/AUTHENTIK_SETUP.md`](docs/AUTHENTIK_SETUP.md). +- **Lokala konton** — skapas i setup-guiden och i admin-panelen (bcrypt). +- **Publik användare** — anonym, väljs på login-sidan; ser bara det admin delat. + +**Roller:** medlemmar i gruppen `Archivum-admin` är administratörer (ser/gör allt, +administrerar sidan). Alla andra styrs av åtkomstreglerna. + +**Åtkomstmodell:** per sökväg (dokument eller mapp) tilldelar admin `allow`/`deny` +till användare och grupper. Standard är **deny**; en `allow` ger tillgång, en +`deny` vinner alltid. Reglerna kombineras över användarens alla grupper och +överliggande mappar. Hanteras under **Admin Settings → Access Control**. + +## Automatiskt bygge (Gitea Actions) + +Vid push till `main` bygger den självhostade Gitea-runnern (arm64, på Pi5) +imagen och pushar den till registryn: + +``` +localhost:5000/archivum:latest +localhost:5000/archivum: +``` + +Workflow: [`.gitea/workflows/build.yaml`](.gitea/workflows/build.yaml). Deploya +sedan `localhost:5000/archivum:latest` från din compose-stack på Pi5. + ## Dokumentformat Alla dokument skrivs i [AsciiDoc](https://asciidoc.org/). Exempeldokument: diff --git a/RENDERING_IMPROVEMENTS.md b/RENDERING_IMPROVEMENTS.md new file mode 100644 index 0000000..a318d55 --- /dev/null +++ b/RENDERING_IMPROVEMENTS.md @@ -0,0 +1,120 @@ +# Förbättringsförslag — rendering & editor + +Genomgång av renderings- och editorlagret (`frontend/src/bridge/*`, +`components/editor/*`) med konkreta, prioriterade förslag. En bekräftad bugg är +redan åtgärdad i denna gren (se punkt 0). + +--- + +## 0. ÅTGÄRDAD bugg: inline-formatering tappades vid sparning + +I `asciidoc-bridge.ts → renderTextNodes` byggdes den markerade strängen +(`*fet*`, `_kursiv_`, `` `kod` ``, länkar) men **lades aldrig till utdata** — i +stället lades den *omarkerade* texten till i ett andra `if`-block. Följd: all +inline-formatering försvann tyst varje gång ett dokument sparades från den +visuella editorn. Nu appliceras och skrivs marks korrekt (en enda gång). + +> Rekommendation: lägg ett litet round-trip-test (`adoc → toTipTap → fromTipTap`) +> som CI-steg så att den här klassen av regressioner fångas automatiskt. + +--- + +## 1. Round-trip-modellen är den största risken + +Idag är flödet vid varje sparning: + +``` +.adoc ──asciidoctor.convert──▶ HTML ──generateJSON──▶ TipTap-JSON + ▲ │ + └─────────── fromTipTap (handskriven) ◀──────────────────┘ +``` + +Två lossy konverteringar i rad. Allt som TipTap-schemat inte känner till +(attribut, `ifdef`/`ifeval`, `:attribut:`-definitioner, block-roller, kommentarer, +tabellformat, korsreferenser) går förlorat så fort man öppnar och sparar i den +visuella vyn. + +**Förslag (välj en):** +- **A. Källan är sanningen.** Behandla `.adoc` som master. Visuella editorn får + bara redigera dokument som är "rena" (kan round-trippa förlustfritt), annars + öppnas källeditorn. Visa en varning "det här dokumentet innehåller avancerad + AsciiDoc — redigera i källvyn". +- **B. Patch-baserad sparning.** Spara bara de block som faktiskt ändrats i den + visuella vyn i stället för att reserialisera hela dokumentet. +- **C. Byt serialisering.** Använd en riktig AST (t.ex. `downdoc`/`@asciidoctor` + reducer) i stället för den handskrivna `TipTapToAsciidoc`-klassen. + +Minst arbete/störst nytta på kort sikt: **A** + en tydlig indikator i UI. + +--- + +## 2. Konkreta serialiseringsluckor i `fromTipTap` + +| Nod / mark | Problem | Förslag | +|------------|---------|---------| +| Kombinerade marks (fet+kursiv) | Ger `*_text_*` — bräckligt intill andra tecken | Använd oконstruerad syntax (`**`, `__`) vid behov | +| Tabeller (`convertTable`) | Ingen header-rad (`[%header]`), inga kolumn-specar, colspan/align tappas | Detektera `tableHeader`, skriv `[options="header"]`, `cols=` | +| Listor (`convertListItem`) | Blandning av stycke + nästlad lista i samma item blir fel; principallista använder nivå-räkning som spretar | Rendera item-innehåll block för block med `+`-continuation för flerstycke | +| `blockquote` | Attribution/citat-källa tappas | Stöd `[quote, author, source]` | +| `admonition` | Bara 5 typer, alltid versalt block-format | Bevara ursprunglig form (inline `NOTE:` vs `[NOTE]`-block) | +| Bild | `role`/alignment/`link=` tappas | Utöka `AsciidocImage`-attribut och serialisering | + +--- + +## 3. Renderingspipelinen (adoc → HTML) + +- **Regex-omskrivning av admonitions** (`htmlContent.replace(/
0 } -// CreateUser inserts a new user. Returns ErrUserExists if the username is taken. +// ── Users ─────────────────────────────────────────────────────────────────── + +// CreateUser inserts a new local user. Returns ErrUserExists if taken. func (d *DB) CreateUser(username, passHash, role string) error { _, err := d.sql.Exec( - `INSERT INTO users (username, pass_hash, role) VALUES (?, ?, ?)`, + `INSERT INTO users (username, pass_hash, role, allow_login) VALUES (?, ?, ?, 1)`, username, passHash, role, ) if err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") { @@ -108,12 +216,12 @@ func (d *DB) CreateUser(username, passHash, role string) error { // GetUser fetches a single user by username. func (d *DB) GetUser(username string) (*User, error) { row := d.sql.QueryRow( - `SELECT id, username, pass_hash, role, is_ldap, created_at FROM users WHERE username = ?`, + `SELECT id, username, pass_hash, role, is_ldap, allow_login, created_at FROM users WHERE username = ?`, username, ) u := &User{} var createdAt string - err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &u.IsLDAP, &createdAt) + err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &u.IsLDAP, &u.AllowLogin, &createdAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } @@ -127,7 +235,7 @@ func (d *DB) GetUser(username string) (*User, error) { // ListUsers returns all users ordered by id. func (d *DB) ListUsers() ([]*User, error) { rows, err := d.sql.Query( - `SELECT id, username, role, is_ldap, created_at FROM users ORDER BY id`, + `SELECT id, username, role, is_ldap, allow_login, created_at FROM users ORDER BY id`, ) if err != nil { return nil, err @@ -138,7 +246,7 @@ func (d *DB) ListUsers() ([]*User, error) { for rows.Next() { u := &User{} var createdAt string - if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.IsLDAP, &createdAt); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.IsLDAP, &u.AllowLogin, &createdAt); err != nil { return nil, err } u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) @@ -176,49 +284,253 @@ func (d *DB) UpdatePassword(username, passHash string) error { return nil } -// UserCount returns the total number of local accounts. +// SetUserRole updates a user's role (admin/user/guest). +func (d *DB) SetUserRole(username, role string) error { + _, err := d.sql.Exec(`UPDATE users SET role = ? WHERE username = ?`, role, username) + return err +} + +// SetUserLogin toggles whether a user is permitted to sign in. +func (d *DB) SetUserLogin(username string, allow bool) error { + _, err := d.sql.Exec(`UPDATE users SET allow_login = ? WHERE username = ?`, boolInt(allow), username) + return err +} + +// UserCount returns the total number of accounts. func (d *DB) UserCount() (int, error) { var n int err := d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) return n, err } -// --- LDAP & Roles --- +// ── LDAP / OIDC provisioning ───────────────────────────────────────────────── -// 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 { +// CreateOrUpdateExternalUser inserts or updates an externally-authenticated +// user (OIDC/LDAP; no local password). On first insert allow_login is set to +// allowLogin; on subsequent logins the stored allow_login is preserved so an +// admin's manual toggle sticks. Existing accounts with a non-empty pass_hash +// (local admins) keep their role — they are never downgraded. +func (d *DB) CreateOrUpdateExternalUser(username, role string, allowLogin bool) error { + if role == "" { + role = "user" + } _, err := d.sql.Exec(` - INSERT INTO users (username, pass_hash, role, is_ldap) - VALUES (?, '', 'user', 1) - ON CONFLICT(username) DO UPDATE SET is_ldap=1 - WHERE excluded.pass_hash = '' AND pass_hash = ''; - `, username) + INSERT INTO users (username, pass_hash, role, is_ldap, allow_login) + VALUES (?, '', ?, 1, ?) + ON CONFLICT(username) DO UPDATE SET is_ldap=1, role=excluded.role + WHERE users.pass_hash = ''; + `, username, role, boolInt(allowLogin)) return err } -// CreateOrUpdateGroup inserts or updates a group (local or LDAP). +// CreateOrUpdateGroup inserts or updates a group (local or external). func (d *DB) CreateOrUpdateGroup(name string, isLdap bool) error { - ldVal := 0 - if isLdap { - ldVal = 1 - } _, err := d.sql.Exec(` INSERT INTO groups (name, is_ldap) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET is_ldap=excluded.is_ldap; - `, name, ldVal) + `, name, boolInt(isLdap)) return err } -// --- ACL Methods --- +// DeleteGroup removes a group and its memberships/ACLs cascade via FK / manual cleanup. +func (d *DB) DeleteGroup(name string) error { + g, err := d.GetGroup(name) + if err != nil { + return err + } + tx, err := d.sql.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM acl WHERE subject_type='group' AND subject_id=?`, g.ID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM groups WHERE id=?`, g.ID); err != nil { + return err + } + return tx.Commit() +} + +// SetGroupLogin toggles whether members of a group are permitted to sign in. +func (d *DB) SetGroupLogin(name string, allow bool) error { + _, err := d.sql.Exec(`UPDATE groups SET allow_login = ? WHERE name = ?`, boolInt(allow), name) + return err +} + +// GetGroup fetches a group by name. +func (d *DB) GetGroup(name string) (*Group, error) { + row := d.sql.QueryRow(`SELECT id, name, is_ldap, allow_login FROM groups WHERE name = ?`, name) + g := &Group{} + if err := row.Scan(&g.ID, &g.Name, &g.IsLDAP, &g.AllowLogin); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + return g, nil +} + +// ListGroups returns all groups ordered by name. +func (d *DB) ListGroups() ([]*Group, error) { + rows, err := d.sql.Query(`SELECT id, name, is_ldap, allow_login 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, &g.AllowLogin); err != nil { + return nil, err + } + groups = append(groups, g) + } + return groups, rows.Err() +} + +// EnsureBuiltinGroups creates the Archivum-admin / Archivum-reader groups that +// map to application roles. They are marked external (synced from the IdP) and +// login-enabled. +func (d *DB) EnsureBuiltinGroups() error { + for _, name := range []string{GroupAdmin, GroupReader} { + if _, err := d.sql.Exec(` + INSERT INTO groups (name, is_ldap, allow_login) VALUES (?, 1, 1) + ON CONFLICT(name) DO UPDATE SET allow_login=1; + `, name); err != nil { + return err + } + } + return nil +} + +// EnsureGuestUser creates the built-in public/guest account if missing. +func (d *DB) EnsureGuestUser() error { + _, err := d.sql.Exec(` + INSERT INTO users (username, pass_hash, role, allow_login) VALUES ('guest', '', 'guest', 1) + ON CONFLICT(username) DO NOTHING; + `) + return err +} + +// ── Group membership ────────────────────────────────────────────────────────── + +// AddUserToGroup links a user to a group (both by name). +func (d *DB) AddUserToGroup(username, groupName string) error { + u, err := d.GetUser(username) + if err != nil { + return err + } + g, err := d.GetGroup(groupName) + if err != nil { + return err + } + _, err = d.sql.Exec(`INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)`, u.ID, g.ID) + return err +} + +// RemoveUserFromGroup unlinks a user from a group. +func (d *DB) RemoveUserFromGroup(username, groupName string) error { + u, err := d.GetUser(username) + if err != nil { + return err + } + g, err := d.GetGroup(groupName) + if err != nil { + return err + } + _, err = d.sql.Exec(`DELETE FROM user_groups WHERE user_id=? AND group_id=?`, u.ID, g.ID) + return err +} + +// GetUserGroupNames returns the names of all groups a (local) user belongs to. +func (d *DB) GetUserGroupNames(username string) ([]string, error) { + rows, err := d.sql.Query(` + SELECT g.name FROM groups g + JOIN user_groups ug ON ug.group_id = g.id + JOIN users u ON u.id = ug.user_id + WHERE u.username = ? + ORDER BY g.name + `, username) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var n string + if err := rows.Scan(&n); err != nil { + return nil, err + } + out = append(out, n) + } + return out, rows.Err() +} + +// SyncUserGroups ensures the given group names exist and that the user is a +// member of exactly those groups that are also present in the DB. Used to keep +// a local mirror of an OIDC user's group membership for the admin UI. +func (d *DB) SyncUserGroups(username string, groupNames []string) error { + u, err := d.GetUser(username) + if err != nil { + return err + } + tx, err := d.sql.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM user_groups WHERE user_id=?`, u.ID); err != nil { + return err + } + for _, name := range groupNames { + var gid int64 + if err := tx.QueryRow(`SELECT id FROM groups WHERE name=?`, name).Scan(&gid); err != nil { + if errors.Is(err, sql.ErrNoRows) { + continue + } + return err + } + if _, err := tx.Exec(`INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)`, u.ID, gid); err != nil { + return err + } + } + return tx.Commit() +} + +// ── Login gate ──────────────────────────────────────────────────────────────── + +// LoginAllowed reports whether a subject may sign in: the user must have +// allow_login set, or belong to at least one login-enabled group (by name). +// The built-in role groups always permit login. +func (d *DB) LoginAllowed(username string, groupNames []string) bool { + if u, err := d.GetUser(username); err == nil { + if u.Role == "admin" || u.AllowLogin { + return true + } + } + for _, name := range groupNames { + if name == GroupAdmin || name == GroupReader { + return true + } + var allow int + d.sql.QueryRow(`SELECT allow_login FROM groups WHERE name=?`, name).Scan(&allow) + if allow == 1 { + return true + } + } + return false +} + +// ── ACL ──────────────────────────────────────────────────────────────────── type ACLEntry struct { ID int64 Path string SubjectType string SubjectID int64 + Effect string // "allow" | "deny" CanSearch bool CanView bool CanRead bool @@ -228,20 +540,34 @@ type ACLEntry struct { CanMove bool } -// SetACL inserts or replaces an ACL entry. +// Perms is the resolved effective permission set for a subject on a path. +type Perms struct { + Search bool + View bool + Read bool + Edit bool + Create bool + Delete bool + Move bool +} + +// SetACL inserts or replaces an ACL entry keyed on (path, subject, effect). func (d *DB) SetACL(entry ACLEntry) error { + if entry.Effect != "deny" { + entry.Effect = "allow" + } _, err := d.sql.Exec(` - INSERT INTO acl (path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(path, subject_type, subject_id) DO UPDATE SET + INSERT INTO acl (path, subject_type, subject_id, effect, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(path, subject_type, subject_id, effect) DO UPDATE SET can_search = excluded.can_search, - can_view = excluded.can_view, - can_read = excluded.can_read, - can_edit = excluded.can_edit, + can_view = excluded.can_view, + can_read = excluded.can_read, + can_edit = excluded.can_edit, can_create = excluded.can_create, can_delete = excluded.can_delete, - can_move = excluded.can_move; - `, entry.Path, entry.SubjectType, entry.SubjectID, + can_move = excluded.can_move; + `, entry.Path, entry.SubjectType, entry.SubjectID, entry.Effect, entry.CanSearch, entry.CanView, entry.CanRead, entry.CanEdit, entry.CanCreate, entry.CanDelete, entry.CanMove) return err @@ -253,23 +579,13 @@ func (d *DB) RemoveACL(id int64) error { return err } -// GetACLsForSubject retrieves all ACL entries for a specific subject (user or group). -func (d *DB) GetACLsForSubject(subjectType string, subjectID int64) ([]ACLEntry, error) { - rows, err := d.sql.Query(` - SELECT id, path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move - FROM acl WHERE subject_type = ? AND subject_id = ? - ORDER BY path - `, subjectType, subjectID) - if err != nil { - return nil, err - } +func scanACLRows(rows *sql.Rows) ([]ACLEntry, error) { defer rows.Close() - var entries []ACLEntry for rows.Next() { var e ACLEntry if err := rows.Scan( - &e.ID, &e.Path, &e.SubjectType, &e.SubjectID, + &e.ID, &e.Path, &e.SubjectType, &e.SubjectID, &e.Effect, &e.CanSearch, &e.CanView, &e.CanRead, &e.CanEdit, &e.CanCreate, &e.CanDelete, &e.CanMove, ); err != nil { @@ -280,106 +596,125 @@ func (d *DB) GetACLsForSubject(subjectType string, subjectID int64) ([]ACLEntry, return entries, rows.Err() } -// GetACLsForPath retrieves all ACL definitions for a specific document or folder. +const aclCols = `id, path, subject_type, subject_id, effect, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move` + +// GetACLsForSubject retrieves all ACL entries for a specific subject. +func (d *DB) GetACLsForSubject(subjectType string, subjectID int64) ([]ACLEntry, error) { + rows, err := d.sql.Query( + `SELECT `+aclCols+` FROM acl WHERE subject_type = ? AND subject_id = ? ORDER BY path, effect`, + subjectType, subjectID) + if err != nil { + return nil, err + } + return scanACLRows(rows) +} + +// GetACLsForPath retrieves all ACL definitions for a specific path. func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) { - rows, err := d.sql.Query(` - SELECT id, path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move - FROM acl WHERE path = ? - `, path) + rows, err := d.sql.Query(`SELECT `+aclCols+` FROM acl WHERE path = ?`, path) if err != nil { return nil, err } - defer rows.Close() + return scanACLRows(rows) +} - var entries []ACLEntry - for rows.Next() { - var e ACLEntry - if err := rows.Scan( - &e.ID, &e.Path, &e.SubjectType, &e.SubjectID, - &e.CanSearch, &e.CanView, &e.CanRead, &e.CanEdit, - &e.CanCreate, &e.CanDelete, &e.CanMove, - ); err != nil { - return nil, err - } - entries = append(entries, e) +// EffectiveAccess resolves the permissions a user has on a path by combining +// every ACL entry that applies to the user directly or to any of the given +// groups, across the path and all of its ancestor folders. +// +// Resolution rule (as specified): default deny. An explicit allow grants a +// permission; an explicit deny anywhere in the applicable set always wins over +// an allow. So a permission is granted only if some entry allows it and no +// entry denies it. +func (d *DB) EffectiveAccess(username string, groupNames []string, path string) Perms { + ancestors := pathAncestors(path) + ancestorSet := make(map[string]bool, len(ancestors)) + for _, p := range ancestors { + ancestorSet[p] = true } - return entries, nil -} -// Group represents a group stored in SQLite. -type Group struct { - ID int64 - Name string - IsLDAP bool -} + // Build the subject filter: this user + these groups. + var where []string + var args []interface{} + if uid, ok := d.userID(username); ok { + where = append(where, `(subject_type='user' AND subject_id=?)`) + args = append(args, uid) + } + if gids := d.groupIDs(groupNames); len(gids) > 0 { + ph := make([]string, len(gids)) + for i, id := range gids { + ph[i] = "?" + args = append(args, id) + } + where = append(where, `(subject_type='group' AND subject_id IN (`+strings.Join(ph, ",")+`))`) + } + if len(where) == 0 { + return Perms{} + } -// 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`) + rows, err := d.sql.Query( + `SELECT `+aclCols+` FROM acl WHERE `+strings.Join(where, " OR "), args...) if err != nil { - return nil, err + return Perms{} } - 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 + entries, err := scanACLRows(rows) + if err != nil { + return Perms{} + } + + var allow, deny Perms + for _, e := range entries { + if !ancestorSet[e.Path] { + continue } - groups = append(groups, g) + target := &allow + if e.Effect == "deny" { + target = &deny + } + target.Search = target.Search || e.CanSearch + target.View = target.View || e.CanView + target.Read = target.Read || e.CanRead + target.Edit = target.Edit || e.CanEdit + target.Create = target.Create || e.CanCreate + target.Delete = target.Delete || e.CanDelete + target.Move = target.Move || e.CanMove + } + + return Perms{ + Search: allow.Search && !deny.Search, + View: allow.View && !deny.View, + Read: allow.Read && !deny.Read, + Edit: allow.Edit && !deny.Edit, + Create: allow.Create && !deny.Create, + Delete: allow.Delete && !deny.Delete, + Move: allow.Move && !deny.Move, } - 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 +func (d *DB) userID(username string) (int64, bool) { + var id int64 + err := d.sql.QueryRow(`SELECT id FROM users WHERE username=?`, username).Scan(&id) + if err != nil { + return 0, false + } + return id, true } -// 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 +func (d *DB) groupIDs(names []string) []int64 { + var out []int64 + for _, n := range names { + var id int64 + if err := d.sql.QueryRow(`SELECT id FROM groups WHERE name=?`, n).Scan(&id); err == nil { + out = append(out, id) } } - 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 + return out } // 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 { + path = strings.TrimSuffix(path, "/") out := []string{path} for { idx := strings.LastIndex(path, "/") @@ -391,3 +726,10 @@ func pathAncestors(path string) []string { } return out } + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/backend/internal/graph/schema.graphql b/backend/internal/graph/schema.graphql index cc4ca26..e861a44 100644 --- a/backend/internal/graph/schema.graphql +++ b/backend/internal/graph/schema.graphql @@ -49,6 +49,18 @@ type Query { # Returns the role of the currently authenticated user ("admin", "user", "guest"). currentUserRole: String! + + # Sign-in methods the login page should offer (public, no auth required). + loginOptions: LoginOptions! + + # All local/external users with role & login state (admin only). + users: [UserInfo!]! + + # Current OpenID Connect (Authentik) configuration (admin only, no secret). + oidcConfig: OIDCConfigView! + + # Names of the groups a user belongs to (admin only). + userGroups(username: String!): [String!]! } type Mutation { @@ -73,9 +85,25 @@ type Mutation { # Modify LDAP Configuration (admin only). updateLdapConfig(input: LDAPInput): Boolean! + # Modify OpenID Connect (Authentik) configuration (admin only). + updateOidcConfig(input: OIDCInput!): Boolean! + # Sync users/groups from LDAP importLdapSubject(type: String!, name: String!): Boolean! + # Group management (admin only). + createGroup(name: String!): Boolean! + deleteGroup(name: String!): Boolean! + addUserToGroup(username: String!, group: String!): Boolean! + removeUserFromGroup(username: String!, group: String!): Boolean! + + # Who may sign in (admin only). + setUserLogin(username: String!, allow: Boolean!): Boolean! + setGroupLogin(name: String!, allow: Boolean!): Boolean! + + # Change a user's application role: "admin" | "user" (admin only). + setUserRole(username: String!, role: String!): Boolean! + # ACL Mutations setAcl(input: ACLInput!): Boolean! removeAcl(id: Int!): Boolean! @@ -127,6 +155,7 @@ type ACLEntry { path: String! subjectType: String! subjectId: Int! + effect: String! # "allow" | "deny" — deny always wins over allow canSearch: Boolean! canView: Boolean! canRead: Boolean! @@ -136,6 +165,35 @@ type ACLEntry { canMove: Boolean! } +type LoginOptions { + localEnabled: Boolean! + oidcEnabled: Boolean! + oidcButtonLabel: String! + publicEnabled: Boolean! +} + +type UserInfo { + username: String! + role: String! + isLdap: Boolean! + allowLogin: Boolean! + createdAt: String! +} + +type OIDCConfigView { + enabled: Boolean! + ready: Boolean! + issuer: String! + clientId: String! + clientSecretSet: Boolean! + redirectUrl: String! + publicUrl: String! + groupsClaim: String! + usernameClaim: String! + adminGroup: String! + readerGroup: String! +} + type ServerDirectory { name: String! path: String! @@ -198,9 +256,11 @@ input LDAPInput { } type ACLSubject { - id: Int! - name: String! - isLdap: Boolean! + id: Int! + name: String! + isLdap: Boolean! + role: String # only present for users + allowLogin: Boolean! } type ACLSubjects { @@ -212,6 +272,7 @@ input ACLInput { path: String! subjectType: String! subjectId: Int! + effect: String # "allow" (default) | "deny" canSearch: Boolean! canView: Boolean! canRead: Boolean! @@ -221,6 +282,19 @@ input ACLInput { canMove: Boolean! } +input OIDCInput { + enabled: Boolean! + issuer: String! + clientId: String! + clientSecret: String # empty = keep existing + redirectUrl: String + publicUrl: String + groupsClaim: String + usernameClaim: String + adminGroup: String + readerGroup: String +} + input SaveDocumentInput { slug: String! content: String! diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index aef60c3..ffadd90 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -2,11 +2,13 @@ package graph import ( "bufio" + "context" "encoding/json" "fmt" "io" "log" "net/http" + "net/url" "os" "path/filepath" "runtime" @@ -30,10 +32,11 @@ type Server struct { database *db.DB authMgr *auth.Manager gitRepo *git.Repo + oidc *auth.OIDCProvider } func NewServer(cfg *config.Config, configPath string) http.Handler { - s := &Server{cfg: cfg, configPath: configPath} + s := &Server{cfg: cfg, configPath: configPath, oidc: auth.NewOIDCProvider()} if cfg != nil { s.initRuntime(cfg) @@ -44,6 +47,8 @@ func NewServer(cfg *config.Config, configPath string) http.Handler { mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/api/upload", s.handleUpload) mux.HandleFunc("/media/", s.handleMedia) + mux.HandleFunc("/auth/oidc/login", s.handleOIDCLogin) + mux.HandleFunc("/auth/oidc/callback", s.handleOIDCCallback) uiDir := os.Getenv("UI_DIR") if uiDir == "" { @@ -75,10 +80,13 @@ func (s *Server) initRuntime(cfg *config.Config) { log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err) } else { d = database - // Ensure guest user exists (handles upgrades from older versions). + // Ensure guest user + built-in role groups exist (handles upgrades). if err := d.EnsureGuestUser(); err != nil { log.Printf("[db] warning: could not ensure guest user: %v", err) } + if err := d.EnsureBuiltinGroups(); err != nil { + log.Printf("[db] warning: could not ensure built-in groups: %v", err) + } log.Printf("[db] opened at %s", cfg.DBPath) } @@ -98,7 +106,31 @@ func (s *Server) initRuntime(cfg *config.Config) { s.database = d s.authMgr = am s.gitRepo = gr + if s.oidc == nil { + s.oidc = auth.NewOIDCProvider() + } s.mu.Unlock() + + s.configureOIDC(cfg) +} + +// configureOIDC (re)initialises the OIDC provider from config. Discovery +// requires reaching the IdP, so it runs in the background to avoid blocking +// startup if Authentik is momentarily unavailable. +func (s *Server) configureOIDC(cfg *config.Config) { + if cfg == nil || s.oidc == nil { + return + } + oc := cfg.OIDC + redirect := cfg.ResolvedRedirectURL() + provider := s.oidc + go func() { + if err := provider.Configure(context.Background(), oc, redirect); err != nil { + log.Printf("[oidc] provider not ready: %v", err) + } else if oc.Enabled { + log.Printf("[oidc] provider configured (issuer=%s, redirect=%s)", oc.Issuer, redirect) + } + }() } // ── GraphQL handler ─────────────────────────────────────────────────────────── @@ -157,6 +189,11 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) { writeJSON(w, `{"data":{"systemStatus":"OK"}}`) } + // Public: what sign-in methods the login page should offer. Must be + // matched before the "login" case since it also contains that substring. + case strings.Contains(q, "loginOptions"): + s.handleLoginOptions(w) + case strings.Contains(q, "testLdapConnection"): s.handleTestLdapConnection(w, req) @@ -188,6 +225,37 @@ func (s *Server) dispatchAuthenticated( q := req.Query switch { + // ── Admin: OIDC / groups / membership / login gate (matched first) ───────── + case strings.Contains(q, "updateOidcConfig"): + s.handleUpdateOidcConfig(w, req, sess) + + case strings.Contains(q, "oidcConfig"): + s.handleOidcConfig(w, sess) + + case strings.Contains(q, "createGroup"): + s.handleCreateGroup(w, req, sess) + + case strings.Contains(q, "deleteGroup"): + s.handleDeleteGroup(w, req, sess) + + case strings.Contains(q, "addUserToGroup"): + s.handleAddUserToGroup(w, req, sess) + + case strings.Contains(q, "removeUserFromGroup"): + s.handleRemoveUserFromGroup(w, req, sess) + + case strings.Contains(q, "setUserLogin"): + s.handleSetUserLogin(w, req, sess) + + case strings.Contains(q, "setGroupLogin"): + s.handleSetGroupLogin(w, req, sess) + + case strings.Contains(q, "setUserRole"): + s.handleSetUserRole(w, req, sess) + + case strings.Contains(q, "userGroups"): + s.handleUserGroups(w, req, sess) + case strings.Contains(q, "saveDocument"): s.handleSaveDocument(w, req, sess) @@ -398,6 +466,38 @@ func (s *Server) sessionFromRequest(r *http.Request) *auth.Session { return sess } +// allPerms is the permission set granted to administrators (who bypass ACLs). +var allPerms = db.Perms{Search: true, View: true, Read: true, Edit: true, Create: true, Delete: true, Move: true} + +// access resolves the effective permissions the session has on a path. +// Administrators always get the full set; everyone else (users, readers, +// guest/public) is resolved through the allow/deny ACL model, which defaults +// to deny. A nil session gets nothing. +func (s *Server) access(sess *auth.Session, path string) db.Perms { + if sess == nil { + return db.Perms{} + } + if sess.Role == "admin" { + return allPerms + } + s.mu.RLock() + database := s.database + s.mu.RUnlock() + if database == nil { + return db.Perms{} + } + return database.EffectiveAccess(sess.Username, sess.Groups, path) +} + +// parentDir returns the folder containing a slug, or "" at the root. +func parentDir(slug string) string { + slug = strings.Trim(slug, "/") + if idx := strings.LastIndex(slug, "/"); idx >= 0 { + return slug[:idx] + } + return "" +} + // ── Document handlers ───────────────────────────────────────────────────────── func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *auth.Session, store *storage.Store) { @@ -423,15 +523,10 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *au 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 - } + // Everyone except admin is gated by the allow/deny ACL model. + if !s.access(sess, slug).Read { + writeGQLError(w, "UNAUTHORIZED") + return } content, err := store.Read(slug) @@ -467,15 +562,12 @@ 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)) - s.mu.RLock() - database := s.database - s.mu.RUnlock() - docs := make([]map[string]string, 0, len(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) { + // Non-admins only see documents they can view, read or search for. + if sess.Role != "admin" { + p := s.access(sess, slug) + if !(p.Read || p.View || p.Search) { continue } } @@ -525,6 +617,13 @@ func (s *Server) handleSaveDocument(w http.ResponseWriter, req gqlRequest, sess commitMsg = "Update " + slug } + // Saving requires edit (existing docs) or create (new docs) permission. + if p := s.access(sess, slug); !(p.Edit || p.Create) { + log.Printf("[acl] save denied for %q on %q", sess.Username, slug) + writeGQLError(w, "UNAUTHORIZED") + return + } + log.Printf("[document] user %q is attempting to save document %q (commit msg: %q)", sess.Username, slug, commitMsg) s.mu.RLock() @@ -584,6 +683,11 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, req gqlRequest, ses return } + if !s.access(sess, slug).Delete { + writeGQLError(w, "UNAUTHORIZED") + return + } + s.mu.RLock() store := s.store s.mu.RUnlock() @@ -704,12 +808,14 @@ func (s *Server) handleUsers(w http.ResponseWriter, sess *auth.Session) { return } - list := make([]map[string]string, 0, len(users)) + list := make([]map[string]interface{}, 0, len(users)) for _, u := range users { - list = append(list, map[string]string{ - "username": u.Username, - "role": u.Role, - "createdAt": u.CreatedAt.Format(time.RFC3339), + list = append(list, map[string]interface{}{ + "username": u.Username, + "role": u.Role, + "isLdap": u.IsLDAP, + "allowLogin": u.AllowLogin, + "createdAt": u.CreatedAt.Format(time.RFC3339), }) } @@ -748,6 +854,7 @@ func (s *Server) handleSubjectAcl(w http.ResponseWriter, req gqlRequest, sess *a "path": a.Path, "subjectType": a.SubjectType, "subjectId": a.SubjectID, + "effect": a.Effect, "canSearch": a.CanSearch, "canView": a.CanView, "canRead": a.CanRead, @@ -807,17 +914,20 @@ func (s *Server) handleAclSubjects(w http.ResponseWriter, sess *auth.Session) { 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, + "id": u.ID, + "name": u.Username, + "isLdap": u.IsLDAP, + "role": u.Role, + "allowLogin": u.AllowLogin, }) } 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, + "id": g.ID, + "name": g.Name, + "isLdap": g.IsLDAP, + "allowLogin": g.AllowLogin, }) } @@ -1238,6 +1348,10 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { if err := database.EnsureGuestUser(); err != nil { log.Printf("[setup] warning: could not create guest user: %v", err) } + // Ensure the Archivum-admin / Archivum-reader role groups exist. + if err := database.EnsureBuiltinGroups(); err != nil { + log.Printf("[setup] warning: could not create built-in groups: %v", err) + } log.Printf("[setup] admin user %q created", adminUser) @@ -1269,8 +1383,13 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { s.database = database s.authMgr = auth.NewManager(cfg) s.gitRepo = repo + if s.oidc == nil { + s.oidc = auth.NewOIDCProvider() + } s.mu.Unlock() + s.configureOIDC(cfg) + log.Printf("[setup] complete") writeJSON(w, `{"data":{"setup":true}}`) } @@ -1501,7 +1620,7 @@ func (s *Server) handleImportLdapSubject(w http.ResponseWriter, req gqlRequest, var err error if typ == "user" { - err = database.CreateOrUpdateLDAPUser(name) + err = database.CreateOrUpdateExternalUser(name, "user", true) } else if typ == "group" { err = database.CreateOrUpdateGroup(name, true) } else { @@ -1536,6 +1655,10 @@ func (s *Server) handleSetAcl(w http.ResponseWriter, req gqlRequest, sess *auth. var entry db.ACLEntry entry.Path = strVal(input, "path") entry.SubjectType = strVal(input, "subjectType") + entry.Effect = strVal(input, "effect") + if entry.Effect != "deny" { + entry.Effect = "allow" + } if idF, ok := input["subjectId"].(float64); ok { entry.SubjectID = int64(idF) } else { @@ -1611,6 +1734,7 @@ func (s *Server) handleAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Ses "path": a.Path, "subjectType": a.SubjectType, "subjectId": a.SubjectID, + "effect": a.Effect, "canSearch": a.CanSearch, "canView": a.CanView, "canRead": a.CanRead, @@ -1662,6 +1786,11 @@ func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth return } + if !s.access(sess, slug).Read { + writeGQLError(w, "UNAUTHORIZED") + return + } + s.mu.RLock() repo := s.gitRepo s.mu.RUnlock() @@ -1701,6 +1830,11 @@ func (s *Server) handleDiff(w http.ResponseWriter, req gqlRequest, sess *auth.Se fromHash, _ := req.Variables["fromHash"].(string) toHash, _ := req.Variables["toHash"].(string) + if !s.access(sess, slug).Read { + writeGQLError(w, "UNAUTHORIZED") + return + } + s.mu.RLock() repo := s.gitRepo s.mu.RUnlock() @@ -1727,6 +1861,11 @@ func (s *Server) handleDocumentAtCommit(w http.ResponseWriter, req gqlRequest, s slug, _ := req.Variables["slug"].(string) hash, _ := req.Variables["hash"].(string) + if !s.access(sess, slug).Read { + writeGQLError(w, "UNAUTHORIZED") + return + } + s.mu.RLock() repo := s.gitRepo s.mu.RUnlock() @@ -1757,6 +1896,11 @@ func (s *Server) handleCreateFolder(w http.ResponseWriter, req gqlRequest, sess return } + if p := s.access(sess, path); !(p.Create || p.Edit) { + writeGQLError(w, "UNAUTHORIZED") + return + } + s.mu.RLock() store := s.store s.mu.RUnlock() @@ -1794,6 +1938,16 @@ func (s *Server) handleMoveDocument(w http.ResponseWriter, req gqlRequest, sess return } + // Moving requires move permission on the source and write on the destination. + if !s.access(sess, oldSlug).Move { + writeGQLError(w, "UNAUTHORIZED") + return + } + if dst := s.access(sess, newSlug); !(dst.Edit || dst.Create) { + writeGQLError(w, "UNAUTHORIZED") + return + } + s.mu.RLock() store := s.store repo := s.gitRepo @@ -1864,6 +2018,11 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { return } + if p := s.access(sess, slug); !(p.Edit || p.Create) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + file, header, err := r.FormFile("image") if err != nil { http.Error(w, "Missing image", http.StatusBadRequest) @@ -1922,7 +2081,6 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) { s.mu.RLock() store := s.store - database := s.database s.mu.RUnlock() if store == nil { @@ -1937,11 +2095,12 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) { return } - // Guest users may only see folders they have been granted view access to. - if sess.Role == "guest" && database != nil { + // Non-admins only see folders they may view/read/search into. + if sess.Role != "admin" { visible := folders[:0] for _, f := range folders { - if database.CanUserViewPath(sess.Username, f) { + p := s.access(sess, f) + if p.View || p.Read || p.Search { visible = append(visible, f) } } @@ -1968,6 +2127,11 @@ func (s *Server) handleImages(w http.ResponseWriter, req gqlRequest, sess *auth. return } + if !s.access(sess, slug).Read { + writeGQLError(w, "UNAUTHORIZED") + return + } + s.mu.RLock() store := s.store s.mu.RUnlock() @@ -2000,6 +2164,11 @@ func (s *Server) handleDeleteImage(w http.ResponseWriter, req gqlRequest, sess * return } + if !s.access(sess, slug).Edit { + writeGQLError(w, "UNAUTHORIZED") + return + } + s.mu.RLock() store := s.store s.mu.RUnlock() @@ -2017,3 +2186,359 @@ func (s *Server) handleDeleteImage(w http.ResponseWriter, req gqlRequest, sess * log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username) writeJSON(w, `{"data":{"deleteImage":true}}`) } + +// ── OIDC HTTP endpoints ───────────────────────────────────────────────────────── + +func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + provider := s.oidc + s.mu.RUnlock() + if provider == nil || !provider.Enabled() { + http.Error(w, "OIDC sign-in is not configured", http.StatusServiceUnavailable) + return + } + authURL, err := provider.AuthURL() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + http.Redirect(w, r, authURL, http.StatusFound) +} + +func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + provider := s.oidc + database := s.database + mgr := s.authMgr + s.mu.RUnlock() + + if provider == nil || database == nil || mgr == nil { + s.redirectLoginError(w, r, "server not ready") + return + } + + q := r.URL.Query() + if e := q.Get("error"); e != "" { + desc := q.Get("error_description") + if desc == "" { + desc = e + } + s.redirectLoginError(w, r, desc) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + oidcUser, err := provider.Exchange(ctx, q.Get("state"), q.Get("code")) + if err != nil { + log.Printf("[oidc] token exchange failed: %v", err) + s.redirectLoginError(w, r, "sign-in failed") + return + } + + token, role, err := mgr.LoginOIDC(database, oidcUser) + if err != nil { + log.Printf("[oidc] login denied for %q: %v", oidcUser.Username, err) + s.redirectLoginError(w, r, err.Error()) + return + } + + log.Printf("[oidc] login: %s (%s) groups=%v", oidcUser.Username, role, oidcUser.Groups) + + // The SPA reads these from the URL fragment; the fragment is never sent to + // a server, so the token stays on the client (mirrors the localStorage model). + frag := fmt.Sprintf("#token=%s&username=%s&role=%s", + url.QueryEscape(token), url.QueryEscape(oidcUser.Username), url.QueryEscape(role)) + http.Redirect(w, r, "/oidc/callback"+frag, http.StatusFound) +} + +func (s *Server) redirectLoginError(w http.ResponseWriter, r *http.Request, msg string) { + http.Redirect(w, r, "/oidc/callback#error="+url.QueryEscape(msg), http.StatusFound) +} + +// ── Login options (public) ─────────────────────────────────────────────────────── + +func (s *Server) handleLoginOptions(w http.ResponseWriter) { + s.mu.RLock() + provider := s.oidc + database := s.database + s.mu.RUnlock() + + oidcEnabled := provider != nil && provider.Enabled() + label := "Sign in with Authentik" + + publicEnabled := false + if database != nil { + if _, err := database.GetUser("guest"); err == nil { + publicEnabled = true + } + } + + writeJSONObj(w, map[string]interface{}{ + "data": map[string]interface{}{ + "loginOptions": map[string]interface{}{ + "localEnabled": true, + "oidcEnabled": oidcEnabled, + "oidcButtonLabel": label, + "publicEnabled": publicEnabled, + }, + }, + }) +} + +// ── Admin helpers ───────────────────────────────────────────────────────────── + +// requireAdmin writes an UNAUTHORIZED error and returns false if the session +// is missing or not an administrator. +func (s *Server) requireAdmin(w http.ResponseWriter, sess *auth.Session) bool { + if sess == nil || sess.Role != "admin" { + writeGQLError(w, "UNAUTHORIZED") + return false + } + return true +} + +func (s *Server) db() *db.DB { + s.mu.RLock() + defer s.mu.RUnlock() + return s.database +} + +// ── OIDC config (admin) ──────────────────────────────────────────────────────── + +func (s *Server) handleOidcConfig(w http.ResponseWriter, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + s.mu.RLock() + cfg := s.cfg + provider := s.oidc + s.mu.RUnlock() + if cfg == nil { + writeGQLError(w, "server not initialised") + return + } + oc := cfg.OIDC + oc.Normalize() + writeJSONObj(w, map[string]interface{}{ + "data": map[string]interface{}{ + "oidcConfig": map[string]interface{}{ + "enabled": oc.Enabled, + "ready": provider != nil && provider.Enabled(), + "issuer": oc.Issuer, + "clientId": oc.ClientID, + "clientSecretSet": oc.ClientSecret != "", + "redirectUrl": cfg.ResolvedRedirectURL(), + "publicUrl": cfg.PublicURL, + "groupsClaim": oc.GroupsClaim, + "usernameClaim": oc.UsernameClaim, + "adminGroup": oc.AdminGroup, + "readerGroup": oc.ReaderGroup, + }, + }, + }) +} + +func (s *Server) handleUpdateOidcConfig(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + input, ok := req.Variables["input"].(map[string]interface{}) + if !ok { + input, _ = req.Variables["i"].(map[string]interface{}) + } + if input == nil { + writeGQLError(w, "missing input") + return + } + + s.mu.RLock() + cfg := s.cfg + s.mu.RUnlock() + if cfg == nil { + writeGQLError(w, "server not initialised") + return + } + + newCfg := *cfg + oc := config.OIDCConfig{ + Enabled: boolVal(input, "enabled"), + Issuer: strings.TrimSpace(strVal(input, "issuer")), + ClientID: strings.TrimSpace(strVal(input, "clientId")), + ClientSecret: strVal(input, "clientSecret"), + RedirectURL: strings.TrimSpace(strVal(input, "redirectUrl")), + GroupsClaim: strVal(input, "groupsClaim"), + UsernameClaim: strVal(input, "usernameClaim"), + AdminGroup: strVal(input, "adminGroup"), + ReaderGroup: strVal(input, "readerGroup"), + } + // Empty client secret means "keep existing". + if oc.ClientSecret == "" { + oc.ClientSecret = cfg.OIDC.ClientSecret + } + oc.Normalize() + newCfg.OIDC = oc + if pu := strings.TrimSpace(strVal(input, "publicUrl")); pu != "" { + newCfg.PublicURL = pu + } + + if err := config.Save(s.configPath, &newCfg); err != nil { + writeGQLError(w, fmt.Sprintf("failed to save config: %v", err)) + return + } + + s.mu.Lock() + s.cfg = &newCfg + mgr := s.authMgr + s.mu.Unlock() + if mgr != nil { + mgr.UpdateConfig(&newCfg) + } + s.configureOIDC(&newCfg) + + log.Printf("[admin] OIDC config updated by %s (enabled=%v)", sess.Username, oc.Enabled) + writeJSON(w, `{"data":{"updateOidcConfig":true}}`) +} + +// ── Group management (admin) ───────────────────────────────────────────────────── + +func (s *Server) handleCreateGroup(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + name := strings.TrimSpace(strVal(req.Variables, "name")) + if name == "" { + writeGQLError(w, "group name is required") + return + } + if err := s.db().CreateOrUpdateGroup(name, false); err != nil { + writeGQLError(w, err.Error()) + return + } + log.Printf("[admin] group created: %s by %s", name, sess.Username) + writeJSON(w, `{"data":{"createGroup":true}}`) +} + +func (s *Server) handleDeleteGroup(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + name := strVal(req.Variables, "name") + if name == db.GroupAdmin || name == db.GroupReader { + writeGQLError(w, "built-in role groups cannot be deleted") + return + } + if err := s.db().DeleteGroup(name); err != nil { + writeGQLError(w, err.Error()) + return + } + log.Printf("[admin] group deleted: %s by %s", name, sess.Username) + writeJSON(w, `{"data":{"deleteGroup":true}}`) +} + +func (s *Server) handleAddUserToGroup(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + username := strVal(req.Variables, "username") + group := strVal(req.Variables, "group") + if err := s.db().AddUserToGroup(username, group); err != nil { + writeGQLError(w, err.Error()) + return + } + writeJSON(w, `{"data":{"addUserToGroup":true}}`) +} + +func (s *Server) handleRemoveUserFromGroup(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + username := strVal(req.Variables, "username") + group := strVal(req.Variables, "group") + if err := s.db().RemoveUserFromGroup(username, group); err != nil { + writeGQLError(w, err.Error()) + return + } + writeJSON(w, `{"data":{"removeUserFromGroup":true}}`) +} + +func (s *Server) handleUserGroups(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + username := strVal(req.Variables, "username") + names, err := s.db().GetUserGroupNames(username) + if err != nil { + writeGQLError(w, err.Error()) + return + } + if names == nil { + names = []string{} + } + writeJSONObj(w, map[string]interface{}{ + "data": map[string]interface{}{"userGroups": names}, + }) +} + +func (s *Server) handleSetUserLogin(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + username := strVal(req.Variables, "username") + allow := boolVal(req.Variables, "allow") + if err := s.db().SetUserLogin(username, allow); err != nil { + writeGQLError(w, err.Error()) + return + } + log.Printf("[admin] user %q login set to %v by %s", username, allow, sess.Username) + writeJSON(w, `{"data":{"setUserLogin":true}}`) +} + +func (s *Server) handleSetGroupLogin(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + name := strVal(req.Variables, "name") + allow := boolVal(req.Variables, "allow") + if err := s.db().SetGroupLogin(name, allow); err != nil { + writeGQLError(w, err.Error()) + return + } + log.Printf("[admin] group %q login set to %v by %s", name, allow, sess.Username) + writeJSON(w, `{"data":{"setGroupLogin":true}}`) +} + +func (s *Server) handleSetUserRole(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { + if !s.requireAdmin(w, sess) { + return + } + username := strVal(req.Variables, "username") + role := strVal(req.Variables, "role") + if role != "admin" && role != "user" { + writeGQLError(w, "role must be 'admin' or 'user'") + return + } + if username == "guest" { + writeGQLError(w, "cannot change the public user's role") + return + } + if username == sess.Username && role != "admin" { + writeGQLError(w, "cannot remove your own admin role") + return + } + if err := s.db().SetUserRole(username, role); err != nil { + writeGQLError(w, err.Error()) + return + } + log.Printf("[admin] user %q role set to %s by %s", username, role, sess.Username) + writeJSON(w, `{"data":{"setUserRole":true}}`) +} + +func boolVal(m map[string]interface{}, key string) bool { + if m == nil { + return false + } + b, _ := m[key].(bool) + return b +} diff --git a/docker/Dockerfile b/docker/Dockerfile index 6d486a5..3b832f6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,7 +8,8 @@ COPY frontend/ ./ RUN npm run build # ── Stage 2: Build backend ──────────────────────────────────────────────────── -FROM golang:1.22-alpine AS backend-builder +# Go 1.25 required by the OIDC dependency chain (go-oidc/v3, go-jose/v4). +FROM golang:1.25-alpine AS backend-builder WORKDIR /app/backend # Copy go.mod first for layer caching. go.sum is written by go mod download diff --git a/docs/AUTHENTIK_SETUP.md b/docs/AUTHENTIK_SETUP.md new file mode 100644 index 0000000..7d53637 --- /dev/null +++ b/docs/AUTHENTIK_SETUP.md @@ -0,0 +1,125 @@ +# Authentik (OIDC) + RBAC setup för Archivum + +Archivum autentiserar via **OIDC mot Authentik** (samma mönster som Gitea och +Gym-API i den här miljön). Authentik autentiserar i sin tur mot OpenLDAP, så det +är dina LDAP-användare som loggar in. Grupp-medlemskap kommer med i `groups`-claimet +och styr roll + åtkomst i Archivum. + +Två saker måste finnas: **(1)** grupperna i katalogen och **(2)** en +OAuth2/OIDC-provider + application i Authentik. Steg nedan. + +--- + +## 1. Skapa grupperna + +Skapa två grupper (i Authentik → Directory → Groups, eller i LDAP under +`ou=groups,dc=brasse-pc,dc=eu` som `groupOfNames`): + +| Grupp | Effekt i Archivum | +|-------|-------------------| +| `Archivum-admin` | Full admin — administrerar sidan, tilldelar åtkomst, kringgår ACL | +| `Archivum-reader` | Vanlig inloggad användare — ser bara det som ACL uttryckligen tillåter | + +Lägg dig själv (`bb01`) i `Archivum-admin`. Namnen kan ändras i Archivums +admin-panel (fälten *Admin group* / *Reader group*) om du vill. + +> Grupperna syns i Archivums admin-panel så fort en medlem loggat in en gång — +> `Archivum-admin`/`Archivum-reader` seedas dessutom automatiskt vid start. + +## 2. Skapa en OAuth2/OIDC-provider i Authentik + +Authentik → Applications → Providers → **Create → OAuth2/OpenID Provider**: + +| Fält | Värde | +|------|-------| +| Name | `archivum` | +| Authorization flow | ditt vanliga `default-authorization-flow` (explicit/implicit consent) | +| Client type | **Confidential** | +| Client ID | *(kopiera — behövs i Archivum)* | +| Client Secret | *(kopiera — behövs i Archivum)* | +| Redirect URIs | `https://archivum.brasse-pc.eu/auth/oidc/callback` | +| Signing Key | din vanliga certifikatnyckel | +| Scopes | `openid`, `profile`, `email` **+ en groups-scope** (se nedan) | + +### groups-scope (viktigt) + +Archivum läser gruppnamn ur `groups`-claimet. Återanvänd samma +scope-mapping-mönster som Gitea/Jellyfin redan använder, eller skapa en enkel: + +Authentik → Customization → Property Mappings → **Create → Scope Mapping**: +- Name: `archivum-groups` +- Scope name: `groups` +- Expression: + ```python + return [group.name for group in user.ak_groups.all()] + ``` + +Lägg till den scope-mappingen i providerns *Scopes*. + +## 3. Skapa applikationen + binda åtkomst + +Authentik → Applications → **Create**: +- Name: `Archivum`, Slug: `archivum` +- Provider: `archivum` (den du nyss skapade) + +Issuer-URL blir då: +``` +https://authentik.brasse-pc.eu/application/o/archivum/ +``` + +Bind vilka som får nå appen (Application → Policy/Group/User Bindings) — t.ex. +bara `Archivum-admin` + `Archivum-reader`. Det är den *primära* grinden för vem +som kan logga in; Archivums egen login-lista är ett andra lager. + +## 4. Konfigurera Archivum + +Logga in som den lokala admin som skapades i setup-guiden → **Admin Settings → +Single Sign-On (Authentik / OIDC)**: + +| Fält | Värde | +|------|-------| +| Enable | ✔ | +| Issuer URL | `https://authentik.brasse-pc.eu/application/o/archivum/` | +| Client ID | *(från steg 2)* | +| Client Secret | *(från steg 2)* | +| Public URL | `https://archivum.brasse-pc.eu` | +| Admin group | `Archivum-admin` | +| Reader group | `Archivum-reader` | +| Groups claim | `groups` | +| Username claim | `preferred_username` | + +Spara. Statusen ska bli **"Provider connected ✓"** (Archivum gör OIDC-discovery +mot issuern). Login-sidan visar nu **"Sign in with Authentik"**. + +> Redirect-URI:n som Archivum använder visas i panelen — den **måste** matcha +> exakt det du la in i Authentik-providern. + +--- + +## Så fungerar rollen + åtkomsten + +- Medlem i **Archivum-admin** → roll `admin` → ser/gör allt, administrerar sidan. +- Alla andra inloggade (t.ex. **Archivum-reader**) → styrs helt av + åtkomstreglerna (ACL) i admin-panelen. **Standard = deny.** +- **Publik användare** ("Continue as public user" på login-sidan) → anonym, + ser bara det admin uttryckligen delat. + +### Åtkomstmodell (allow / deny) + +I **Admin Settings → Access Control**: välj en användare eller grupp och lägg +regler per sökväg (dokument-slug eller mapp). + +- **allow** ger en rättighet på en sökväg *och allt under den*. +- **deny** vinner alltid över allow (kombineras över användarens alla grupper + och överliggande mappar). +- Finns ingen regel alls → **deny** (default). + +Exempel: ge `Archivum-reader` `allow` **read+view** på mappen `handbok`, men lägg +`deny` **read** på `handbok/hemligt` för samma grupp → de ser hela handboken utom +den hemliga delen. + +## Reverse proxy (NPM) + +Lägg upp `archivum.brasse-pc.eu` → Archivum-containern (port 4000) i NPM med +Let's Encrypt, precis som övriga tjänster. OIDC-redirecten kräver att appen nås +på den publika HTTPS-URL:en som är registrerad i Authentik. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 2426442..4d9cf63 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -12,6 +12,8 @@ const ready = ref(false) onMounted(async () => { theme.init() + // Consume an OIDC callback (?/oidc/callback#token=…) before deciding view. + app.handleOidcCallback() await app.checkStatus() ready.value = true }) diff --git a/frontend/src/bridge/asciidoc-bridge.ts b/frontend/src/bridge/asciidoc-bridge.ts index b78baa5..527c873 100644 --- a/frontend/src/bridge/asciidoc-bridge.ts +++ b/frontend/src/bridge/asciidoc-bridge.ts @@ -240,14 +240,17 @@ class TipTapToAsciidoc { nodes.forEach((t) => { if (t.type === 'text') { let text = t.text || '' + // Apply inline marks. Note: the previous implementation built the + // marked string but never appended it, then appended the *unmarked* + // text — silently dropping bold/italic/code/strike/link on save. if (t.marks) { - t.marks.forEach(mark => { + t.marks.forEach((mark) => { switch (mark.type) { case 'bold': text = `*${text}*`; break case 'italic': text = `_${text}_`; break case 'strike': text = `[line-through]#${text}#`; break case 'code': text = `\`${text}\``; break - case 'link': + case 'link': { const url = mark.attrs?.href || '' if (url.startsWith('http://') || url.startsWith('https://')) { text = `${url}[${text}]` @@ -255,17 +258,14 @@ class TipTapToAsciidoc { text = `link:${url}[${text}]` } break + } } }) } + textOut += text } else if (t.type === 'footnote') { textOut += `footnote:[${t.attrs?.content || ''}]` - } - if (t.type === 'text') { - let text = t.text || '' - textOut += text - } - if (t.type === 'hardBreak') { + } else if (t.type === 'hardBreak') { textOut += ' +\n' } }) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7d9ef6d..3b358f8 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -24,6 +24,14 @@ export const router = createRouter({ name: 'admin', component: () => import('@/views/AdminView.vue'), }, + { + // OIDC redirect target. The token arrives in the URL fragment and is + // consumed by App.vue's onMounted before this renders; kept as an + // explicit route so the catch-all redirect does not swallow it. + path: '/oidc/callback', + name: 'oidc-callback', + component: HomeView, + }, { path: '/:pathMatch(.*)*', redirect: '/', diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index 8602b3c..90163bf 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -8,6 +8,37 @@ export const useAppStore = defineStore('app', () => { const token = ref(localStorage.getItem('token')) const username = ref(localStorage.getItem('username')) const role = ref(localStorage.getItem('role')) + // Surfaced on the login page (e.g. after a denied OIDC sign-in). + const loginError = ref('') + + // Redirect the browser to the backend OIDC entry point. The backend bounces + // to Authentik and, on success, back to /oidc/callback with a token fragment. + function loginWithOidc() { + window.location.href = '/auth/oidc/login' + } + + // Handle the /oidc/callback route: read the token (or error) from the URL + // fragment, persist the session, and clean the address bar. Returns true if + // this was an OIDC callback navigation. + function handleOidcCallback(): boolean { + if (window.location.pathname !== '/oidc/callback') return false + const raw = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : '' + const params = new URLSearchParams(raw) + const tok = params.get('token') + const err = params.get('error') + if (tok) { + token.value = tok + username.value = params.get('username') || '' + role.value = params.get('role') || '' + localStorage.setItem('token', tok) + localStorage.setItem('username', username.value || '') + localStorage.setItem('role', role.value || '') + } else if (err) { + loginError.value = err + } + window.history.replaceState({}, '', '/') + return true + } async function checkStatus() { try { @@ -95,6 +126,9 @@ export const useAppStore = defineStore('app', () => { localStorage.removeItem('role') } - return { requiresSetup, token, username, role, checkStatus, login, loginAsGuest, logout } + return { + requiresSetup, token, username, role, loginError, + checkStatus, login, loginAsGuest, loginWithOidc, handleOidcCallback, logout, + } }) diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 348efa1..9afd7bb 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,633 +1,574 @@