Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions .gestalt/plans/pocket-id-auth.org

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ node_modules/
.envrc
__pycache__
*.xlsx
timesheetpy/*.png
timesheetpy/*.jpg

16 changes: 8 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,21 @@
- The app reads monthly `.xlsx` timesheets, loads project definitions from YAML files in a separate budgets repository, derives hours and costs, and renders HTML reports.
- The storage model is mixed:
- project metadata and uploaded spreadsheets live in a Git-backed budgets directory;
- authentication goes through an auth boundary, with PocketBase currently implemented and a dev-only fallback for local testing.
- authentication is selected via `agiladmin.auth.backend` and currently supports PocketBase, Pocket ID, or dev auth.

## Stack
- Language: Clojure 1.12.4
- Build tool: Clojure CLI (`deps.edn`)
- Web: Ring + Compojure
- HTML: Hiccup-style vectors rendered by view namespaces
- Data processing: Incanter datasets, Docjure/Apache POI for Excel, YAML parsing via `yaml.core`
- Auth: PocketBase via `src/agiladmin/auth/core.clj`, plus a development auth backend
- Auth: backend abstraction in `src/agiladmin/auth/`
- Git integration: `clj-jgit`

## Entry Points
- Main HTTP routes are in `src/agiladmin/handlers.clj`.
- Application initialization is in `src/agiladmin/ring.clj`.
- `ring/init` loads configuration, ensures the SSH key exists, connects to MongoDB, and initializes auth stores.
- `ring/init` loads configuration, ensures the SSH key exists, and initializes the selected auth backend.
- Core spreadsheet and project logic is in `src/agiladmin/core.clj`.
- The main user-facing views are split by domain:
- `src/agiladmin/view_project.clj`
Expand All @@ -46,7 +46,7 @@
- `:budgets`
- `:webserver`
- `:source`
- `:just-auth`
- `:auth`
- Project configs are separate YAML files stored under the configured budgets path and loaded by `load-project`.
- Tests use fixture config under `test/assets/agiladmin.yaml`.

Expand Down Expand Up @@ -83,9 +83,8 @@
- Covered areas:
- config parsing and schema validation
- spreadsheet ingestion and cost derivation
- auth backends and session behavior
- selected route and view behavior
- minimal `ring/init` smoke test
- utility functions
- auth adapters, route behavior, and `ring/init` backend selection
- Not well covered:
- HTTP route behavior
- auth flows
Expand All @@ -104,7 +103,7 @@
- `src/agiladmin/view_timesheet.clj`
- upload, temp-file handling, Git add/commit/push, and filesystem assumptions are all coupled.
- `src/agiladmin/ring.clj`
- startup performs real side effects: config load, SSH key generation, Mongo connection, auth initialization.
- startup performs real side effects: config load, SSH key generation, PocketBase process management, and auth initialization.
- `src/agiladmin/config.clj`
- config merging and schema handling are permissive and a bit irregular; changes here can affect every feature.

Expand All @@ -113,6 +112,7 @@
- Keep root navigation and navbar home links aligned with the `/persons/list` landing behavior for authenticated users.
- When changing spreadsheet parsing, validate against `test/assets/2016_timesheet_Luca-Pacioli.xlsx` and the expectations in `test/agiladmin/timesheet_test.clj`.
- When changing config handling, verify both global config loading and per-project YAML loading.
- When changing auth behavior, check both [src/agiladmin/view_auth.clj](/home/jrml/devel/agiladmin/src/agiladmin/view_auth.clj) and the adapter under [src/agiladmin/auth/](/home/jrml/devel/agiladmin/src/agiladmin/auth/). Pocket ID is OIDC redirect-based; PocketBase remains password-based.
- Be conservative around `view_timesheet/commit`; it mutates the budgets repo and pushes over SSH.
- Avoid “cleanup” changes that rename columns, normalize casing differently, or alter dataset shapes unless you also update all dependent views/tests.
- Frontend styling uses TailwindCSS + DaisyUI with the `nord` theme; shared layout helpers live in `src/agiladmin/webpage.clj`.
Expand Down
74 changes: 61 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ repository, computes hours and costs, and renders HTML reports for
personnel and projects.

The current codebase targets `org.clojure/clojure` `1.12.4` and starts
with the Clojure CLI. Authentication is backend-driven: PocketBase is
supported for real deployments, and a development-only fallback
backend is available for local manual testing.
with the Clojure CLI. Authentication is backend-driven: PocketBase and
Pocket ID are supported for real deployments, and a development-only
fallback backend is available for local manual testing.

## Current State

- Runtime: Ring + Compojure on Jetty
- Data processing: core.matrix, Docjure / Apache POI, YAML files
- Storage model:
- project metadata and uploaded spreadsheets live in a Git-managed budgets directory
- authentication is handled through a backend abstraction, with PocketBase currently implemented
- authentication is handled through a backend abstraction, with PocketBase and Pocket ID adapters
- Build: Clojure CLI with `deps.edn`
- Tests: Midje

Expand All @@ -32,7 +32,7 @@ The app is well tested and fairly stateful. Startup performs real side effects:
- Java (JRE) and the Clojure CLI
- a writable budgets Git checkout or clone target in `budgets/`
- a valid `agiladmin.yaml` configuration file, or an explicit config path via `AGILADMIN_CONF`
- for real auth flows: a reachable PocketBase instance
- for real auth flows: either a reachable PocketBase instance or a reachable Pocket ID issuer

## Running

Expand Down Expand Up @@ -78,7 +78,7 @@ Or directly:
AGILADMIN_CONF=doc/agiladmin.pocketbase.yaml clj -M:run
```

PocketBase is optional in config, but without either PocketBase or `AGILADMIN_DEV_AUTH=1`, authentication is not initialized and login will not work.
PocketBase is optional in config, but without either an auth backend or `AGILADMIN_DEV_AUTH=1`, authentication is not initialized and login will not work.

Agiladmin expects the PocketBase `users` auth collection to have a `role` select field. Supported values are `admin`, `manager`, or empty.

Expand All @@ -99,7 +99,37 @@ AGILADMIN_CONF=doc/agiladmin.pocketbase.yaml clj -M -m agiladmin.pocketbase-init

If `agiladmin.pocketbase.manage-process` is `true`, Agiladmin starts PocketBase itself, serves it with the configured migrations directory, waits for health, applies the role bootstrap when the installed Agiladmin version changes, and stops PocketBase again on exit.

PocketBase HTTP calls use bounded timeouts by default so startup does not hang forever if the service is unreachable. Override them with `agiladmin.pocketbase.connect-timeout-ms` and `agiladmin.pocketbase.socket-timeout-ms` if needed.
PocketBase HTTP calls use bounded timeouts by default so startup does not hang forever if the service is unreachable. Override them with `agiladmin.auth.pocketbase.connect-timeout-ms` and `agiladmin.auth.pocketbase.socket-timeout-ms` if needed.

### Running With Pocket ID

The repository also ships a Pocket ID example config at [doc/agiladmin.pocket-id.yaml](/home/jrml/devel/agiladmin/doc/agiladmin.pocket-id.yaml).

Run with it using:

```sh
AGILADMIN_CONF=doc/agiladmin.pocket-id.yaml clj -M:run
```

Create an OIDC client in Pocket ID with this redirect URI:

```text
https://<your-agiladmin-host>/auth/pocket-id/callback
```

Recommended scopes are:

```text
openid profile email groups
```

Agiladmin checked Pocket ID documentation on 2026-03-12 and uses the standard OIDC authorization code flow with PKCE, a shared client secret, and role mapping from groups. Configure two Pocket ID groups and map them into Agiladmin with `admin-group` and `manager-group`. If a user belongs to both groups, `admin` wins.

Pocket ID login is redirect-based. The Agiladmin login page shows a single “Sign in with Pocket ID” action and no local password form when this backend is active.

Logout is local-only for now: Agiladmin clears its Ring session and redirects back to `/login`. The Pocket ID session may still be active in the browser.

Pocket ID does not power Agiladmin signup, activation, or pending-user admin flows. Those actions stay in Pocket ID.

## Testing

Expand Down Expand Up @@ -153,6 +183,12 @@ Or with an explicit config file:
AGILADMIN_CONF=doc/agiladmin.pocketbase.yaml java -jar target/<version>-standalone.jar
```

Or:

```sh
AGILADMIN_CONF=doc/agiladmin.pocket-id.yaml java -jar target/<version>-standalone.jar
```

The jar uses the same config lookup as `clj -M:run`: by default it looks for `agiladmin.yaml` in the standard locations, including the current working directory.

## Configuration
Expand Down Expand Up @@ -182,18 +218,28 @@ agiladmin:
git: https://github.com/dyne/agiladmin
update: false

pocketbase:
base-url: http://127.0.0.1:8090
users-collection: users
superuser-email: admin@example.org
superuser-password: change-me
auth:
backend: pocket-id
pocket-id:
issuer-url: https://pocket-id.example.org
client-id: agiladmin
client-secret: change-me
redirect-uri: https://agiladmin.example.org/auth/pocket-id/callback
admin-group: agiladmin-admin
manager-group: agiladmin-manager
scopes:
- openid
- profile
- email
- groups
```

Notes:

- `budgets.ssh-key` is the private key path used for Git access; if it does not exist, Agiladmin generates a new keypair and exposes the public key in the `/config` page
- project names are discovered from `*.yaml` files in `budgets.path`, using the part of the filename before the first `.`
- `pocketbase` is optional only if you are using dev auth locally
- `agiladmin.auth.backend` may be `pocketbase`, `pocket-id`, or `dev`
- legacy top-level `agiladmin.pocketbase` config is still normalized into `agiladmin.auth.pocketbase`

## Project Configuration

Expand Down Expand Up @@ -247,6 +293,7 @@ Notes:
- [src/agiladmin/core.clj](/home/jrml/devel/planb-agiladmin/src/agiladmin/core.clj): spreadsheet and project logic
- [src/agiladmin/view_timesheet.clj](/home/jrml/devel/planb-agiladmin/src/agiladmin/view_timesheet.clj): upload and Git commit flow
- [src/agiladmin/auth/pocketbase.clj](/home/jrml/devel/planb-agiladmin/src/agiladmin/auth/pocketbase.clj): PocketBase auth backend
- [src/agiladmin/auth/pocket_id.clj](/home/jrml/devel/agiladmin/src/agiladmin/auth/pocket_id.clj): Pocket ID OIDC auth backend
- [pb_migrations/](/home/jrml/devel/agiladmin/pb_migrations): PocketBase schema migrations kept for future schema changes
- [test/agiladmin/](/home/jrml/devel/planb-agiladmin/test/agiladmin): Midje test suite

Expand All @@ -255,6 +302,7 @@ Notes:
- Timesheet upload and commit logic writes temporary files under `/tmp/...`
- The budgets repository is mutable application state; timesheet submission performs Git operations
- PocketBase-backed role-aware access depends on a `role` select field on the auth users collection
- Pocket ID-backed role-aware access depends on the configured Pocket ID groups being present in ID token claims or `userinfo`
- Managed PocketBase mode uses a local version marker file to record that the current Agiladmin version has applied its bootstrap step
- The app serves a bundled static HTML README on `/`, so updating this file does not automatically change the in-app landing page

Expand Down
35 changes: 35 additions & 0 deletions doc/agiladmin.pocket-id.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
appname: agiladmin

agiladmin:
webserver:
anti-forgery: false
ssl-redirect: false
port: 8000
host: localhost

budgets:
git: ssh://git@example.org/admin-budgets
ssh-key: id_rsa
path: budgets/

source:
git: https://github.com/dyne/agiladmin
update: false

auth:
backend: pocket-id
pocket-id:
issuer-url: https://pocket-id.example.org
client-id: agiladmin
client-secret: change-me
redirect-uri: https://agiladmin.example.org/auth/pocket-id/callback
post-logout-redirect-uri: https://agiladmin.example.org/login
admin-group: agiladmin-admin
manager-group: agiladmin-manager
scopes:
- openid
- profile
- email
- groups
connect-timeout-ms: 2000
socket-timeout-ms: 2000
26 changes: 14 additions & 12 deletions doc/agiladmin.pocketbase.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,17 @@ agiladmin:
git: https://github.com/dyne/agiladmin
update: false

pocketbase:
base-url: http://127.0.0.1:8090
users-collection: users
superuser-email: admin@example.org
superuser-password: change-me
connect-timeout-ms: 2000
socket-timeout-ms: 2000
manage-process: false
binary: pocketbase
dir: /var/lib/agiladmin/pocketbase
migrations-dir: /usr/local/agiladmin/pocketbase/migrations
version-file: /var/lib/agiladmin/pocketbase/.agiladmin-version
auth:
backend: pocketbase
pocketbase:
base-url: http://127.0.0.1:8090
users-collection: users
superuser-email: admin@example.org
superuser-password: change-me
connect-timeout-ms: 2000
socket-timeout-ms: 2000
manage-process: false
binary: pocketbase
dir: /var/lib/agiladmin/pocketbase
migrations-dir: /usr/local/agiladmin/pocketbase/migrations
version-file: /var/lib/agiladmin/pocketbase/.agiladmin-version
34 changes: 34 additions & 0 deletions doc/pocket-id-auth-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Pocket ID Auth Inventory

This note classifies the current auth use-cases before adding Pocket ID.

## Provider-neutral behavior to keep

- `agiladmin.auth.core/healthy?`
- Session user normalization in `agiladmin.session/normalize-role`
- Ring session storage of the authenticated user
- `view-auth/logout-get`

## PocketBase-specific behavior

- `agiladmin.auth.core/sign-in` with email/password
- `agiladmin.auth.core/sign-up`
- `agiladmin.auth.core/confirm-verification`
- `agiladmin.auth.core/request-verification`
- `agiladmin.auth.core/list-pending-users`
- `view-auth/signup-post`
- `view-auth/activate`

## Behavior that must be replaced for Pocket ID

- `view-auth/login-post` cannot stay password-centric
- `web/login-form` cannot stay email/password-only
- `ring/init` cannot infer the backend from `:agiladmin :pocketbase`
- Pending-user admin behavior must become optional per backend

## Target direction

- Keep the existing auth boundary as a map-based port.
- Add redirect-based login operations for Pocket ID.
- Keep provider-specific capabilities optional instead of mandatory.
- Preserve the Ring session user shape consumed by the rest of the app.
43 changes: 43 additions & 0 deletions doc/pocket-id-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Pocket ID Migration Notes

This is the shortest safe path from PocketBase auth to Pocket ID auth.

## What does not migrate automatically

- users
- passwords
- passkeys
- PocketBase `role` field values
- verification or pending-user state

Pocket ID becomes the identity source of truth. Agiladmin only keeps the logged-in user in the Ring session.

## Role migration

- Create one Pocket ID group for Agiladmin admins.
- Create one Pocket ID group for Agiladmin managers.
- Map them into `agiladmin.auth.pocket-id.admin-group` and `agiladmin.auth.pocket-id.manager-group`.
- Users without either group authenticate successfully but keep a nil Agiladmin role.

## Onboarding change

- PocketBase mode: Agiladmin can show local signup and activation flows.
- Pocket ID mode: onboarding happens in Pocket ID, including passkey enrollment.

## Cutover

1. Create the Pocket ID OIDC client.
2. Set the callback URI to `/auth/pocket-id/callback`.
3. Create and assign the Agiladmin groups in Pocket ID.
4. Switch `agiladmin.auth.backend` to `pocket-id`.
5. Restart Agiladmin.

Existing Agiladmin sessions should be treated as stale during cutover. Users should log in again.

## Rollback

1. Restore the PocketBase config block.
2. Switch `agiladmin.auth.backend` back to `pocketbase`.
3. Restart Agiladmin.

No Agiladmin data migration is required for rollback because auth state is external to the app.
17 changes: 17 additions & 0 deletions packaging/caddyfile.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Snippet for robots.txt
(common_robots_txt) {
handle /robots.txt {
# Set the Content-Type header
header Content-Type "text/plain; charset=utf-8"
# Respond with the body and status code 200
respond `User-agent: *
Disallow: /` 200
}
}

# Pocket-ID
id.xxxx.xx {
import common_robots_txt
# Fallback to reverse proxy for other requests
reverse_proxy 192.168.x.yyy:1411 [xxxx:xxxx:xxxx:xxxx::yyyy]:1411
}
12 changes: 12 additions & 0 deletions packaging/pocketid.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
APP_URL=https://id.xxxx.xx
PORT=1411

# Database: SQLite, file located at /home/pocketid/data/db.sqlite
# (relative to WorkingDirectory=/home/pocketid)
DB_CONNECTION_STRING=file:data/db.sqlite?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate

# Optional: Maxmind License Key for IP Geolocation
MAXMIND_LICENSE_KEY="YOUR-MAXMIND-LICENSE-KEY"

# Optional: Logging level (debug, info, warn, error)
LOG_LEVEL=info
Loading