Rebuild API Server on the PilotVault structure
Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.
Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).
Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.
Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.
Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.
PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.
Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.
Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.
Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.
Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7d55f0a4cd
commit
ae6ed4ac1e
+29
-14
@@ -1,22 +1,37 @@
|
||||
# Copy to .env and fill in. The server reads these at startup.
|
||||
# API Server configuration
|
||||
# Copy to .env and adjust. The server also reads plain environment variables.
|
||||
# Never commit the real .env file.
|
||||
|
||||
# Address the API Server listens on.
|
||||
PORT=8080
|
||||
# Address the API Server listens on. A bare port (8080) is accepted too.
|
||||
API_ADDR=:8080
|
||||
|
||||
# PocketBase instance (database). All DB access goes through this server.
|
||||
PB_URL=http://10.2.1.10:8027
|
||||
# PocketBase base URL (no trailing slash). The API Server is the only thing that
|
||||
# talks to PocketBase; it proxies /api/auth/* to this address, which is never
|
||||
# exposed to clients. Editable at runtime from the panel (PocketBase section),
|
||||
# which writes the change back into this file.
|
||||
POCKETBASE_URL=http://10.2.1.10:8027
|
||||
|
||||
# PocketBase superuser (admin) credentials. Used by the API Server to
|
||||
# authenticate against PocketBase. Never commit the real .env file.
|
||||
PB_ADMIN_EMAIL=
|
||||
PB_ADMIN_PASSWORD=
|
||||
# PocketBase superuser service account. Every privileged flow runs through it:
|
||||
# user/organization management and all car-domain database access. Leave unset
|
||||
# and the server still starts — a superadmin can log in to the panel and
|
||||
# configure it there; management endpoints return 503 until then.
|
||||
POCKETBASE_ADMIN_EMAIL=
|
||||
POCKETBASE_ADMIN_PASSWORD=
|
||||
|
||||
# Comma-separated list of allowed CORS origins for the web app (dev default).
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
# CORS allowed origins for browser clients (comma separated, or * for any).
|
||||
# Native mobile apps are not subject to CORS.
|
||||
CORS_ALLOW_ORIGINS=http://localhost:5173
|
||||
|
||||
# Secret used to sign auth (JWT) tokens. MUST be set to a long random value in
|
||||
# production. If unset, the server falls back to an insecure dev secret.
|
||||
AUTH_SECRET=
|
||||
# Web App address, probed by GET /api/status and shown on the panel.
|
||||
WEBAPP_URL=http://localhost:5173
|
||||
|
||||
# PocketBase auth collection holding app users (default: users).
|
||||
AUTH_USERS_COLLECTION=users
|
||||
|
||||
# Local JSON store for plugin enable-state + config (default: plugins.json).
|
||||
PLUGINS_FILE=plugins.json
|
||||
|
||||
# --- Legacy names -----------------------------------------------------------
|
||||
# PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PORT and CORS_ORIGINS are still
|
||||
# honoured for older deployments; the POCKETBASE_*/API_ADDR names above win when
|
||||
# both are set. AUTH_SECRET is gone — the server no longer mints its own JWTs.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.env
|
||||
plugins.json
|
||||
*.exe
|
||||
*.log
|
||||
/tmp/
|
||||
/bin/
|
||||
panel/node_modules/
|
||||
|
||||
+208
-86
@@ -1,24 +1,99 @@
|
||||
# Car Control — API Server
|
||||
# DriverVault — API Server
|
||||
|
||||
The central API Server for the Car Control project. Written in Go (standard
|
||||
library only, module `carcontrol/api`). It is the **single gateway** between all
|
||||
clients (web app, phone app, Home Assistant plugin, ESP32 device) and the
|
||||
PocketBase database — **clients never talk to PocketBase directly**. The API
|
||||
Server authenticates to PocketBase as superuser and all collection access rules
|
||||
are left null, so data is only reachable through this server.
|
||||
The central API Server for the DriverVault project. Written in Go (standard
|
||||
library only, module `drivervault/apiserver`). It is the **single gateway**
|
||||
between all clients (web app, phone app, Home Assistant plugin, ESP32 device)
|
||||
and the PocketBase database — **clients never talk to PocketBase directly**. The
|
||||
API Server authenticates to PocketBase as a superuser and all collection access
|
||||
rules are left null, so data is only reachable through this server.
|
||||
|
||||
## Data model (from `Car Service.xlsx`)
|
||||
It also serves the **superadmin web panel** at the server root (`/`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
cmd/server/main.go # entry point
|
||||
internal/
|
||||
├── api/ # HTTP handlers + router (server.go)
|
||||
│ ├── auth.go # PocketBase token proxy + role gates
|
||||
│ ├── users.go # user management (role/org scoped)
|
||||
│ ├── orgs.go # organization management
|
||||
│ ├── settings.go # runtime PocketBase connection (pb-config)
|
||||
│ ├── plugins.go # plugin management endpoints
|
||||
│ ├── status.go # upstream health probes
|
||||
│ ├── health.go respond.go panel.go
|
||||
│ ├── cars.go records.go services.go parts.go shares.go me.go
|
||||
│ └── dist/ # built panel, embedded via go:embed
|
||||
├── config/config.go # env + .env load, .env write-back
|
||||
├── pb/client.go # PocketBase superuser client (runtime-retargetable)
|
||||
└── plugins/ # plugin system — see plugins/README.md
|
||||
├── plugin.go manager.go external.go doc.go
|
||||
└── builtin/ # built-in connectors (none yet)
|
||||
panel/ # Vue 3 + Tailwind panel source
|
||||
scripts/ # Node/Python maintenance scripts
|
||||
bin/api-server.exe # prebuilt binary the deployment runs
|
||||
```
|
||||
|
||||
## Auth & access control
|
||||
|
||||
Authentication is **PocketBase's own**. `POST /api/auth/login` is proxied to the
|
||||
PocketBase users collection and the client keeps the token PocketBase minted;
|
||||
this server does not issue its own JWT. Every protected request re-resolves that
|
||||
token against PocketBase (`auth-refresh`), so a role change or a deletion takes
|
||||
effect **immediately** rather than lingering until a token expires.
|
||||
|
||||
```
|
||||
POST /api/auth/login { "email": "...", "password": "..." } -> PocketBase { token, record }
|
||||
GET /api/auth/me (Authorization: <token>) -> { id, email, name, role }
|
||||
GET /api/identity (Authorization: <token>) -> + organization
|
||||
```
|
||||
|
||||
Both `Authorization: Bearer <token>` and a raw `Authorization: <token>` are
|
||||
accepted (PocketBase's own SDKs send the latter).
|
||||
|
||||
### Roles
|
||||
|
||||
`users.role` is `user` | `admin` | `superadmin` (empty is treated as `user`).
|
||||
|
||||
| Role | Can |
|
||||
|---|---|
|
||||
| **user** | their own cars, service records, parts, profile |
|
||||
| **admin** | the above, plus manage users **within their own organization** |
|
||||
| **superadmin** | everything, across all organizations, plus the PocketBase connection and plugins |
|
||||
|
||||
Guards worth knowing: an admin cannot create or edit a superadmin, cannot move
|
||||
users between organizations, and nobody can change their own role or delete
|
||||
their own account. An organization cannot be deleted while it still has members.
|
||||
|
||||
### Organizations
|
||||
|
||||
`organizations` is the tenant collection; `users.organization` is the
|
||||
membership. A superadmin spans all organizations; an admin is scoped by the
|
||||
server to their own. Users may have no organization at all.
|
||||
|
||||
### Per-user car ownership + sharing
|
||||
|
||||
Cars are not a global list. `cars.owner` marks ownership and `car_shares` grants
|
||||
other users `read` or `write` access. Every car/service/part handler is gated by
|
||||
`requireCarAccess`:
|
||||
|
||||
- **read** — view the car, its service records and parts.
|
||||
- **write** — edit the car and full service/part CRUD.
|
||||
- **owner only** — delete the car and manage its shares.
|
||||
|
||||
## Data model
|
||||
|
||||
| Collection | Purpose | Key fields |
|
||||
|---|---|---|
|
||||
| `cars` | one per car (was: one spreadsheet sheet) | name, make, model, year, registration, vin, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `oilSpec`, `transmissionOilSpec`, `differentialOilSpec`, `brakeFluidSpec`, `coolantSpec`, `owner` |
|
||||
| `cars` | one per car | name, make, model, year, registration, vin, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `oilSpec`, `transmissionOilSpec`, `differentialOilSpec`, `brakeFluidSpec`, `coolantSpec`, `owner` |
|
||||
| `service_records` | the service log | car, date, km, changed_oil, changed_engine_air_filter, changed_cabin_air_filter, notes |
|
||||
| `parts` | per-car parts catalog (cols M/N) | car, name, part_number, category |
|
||||
| `parts` | per-car parts catalog | car, name, part_number, category |
|
||||
| `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) |
|
||||
| `sessions` | active login sessions (device/IP/expiry/revoked) | user, label, ip, user_agent, expires, revoked |
|
||||
| `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin), bio, theme, locale, date_format, font_size, deletion_requested_at |
|
||||
| `organizations` | tenants | name (unique) |
|
||||
| `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin \| superadmin), `organization`, bio, theme, locale, date_format, font_size, deletion_requested_at |
|
||||
|
||||
**Spreadsheet formulas**, reproduced by the API on read:
|
||||
**Spreadsheet formulas** (from the original `Car Service.xlsx`), reproduced by
|
||||
the API on read:
|
||||
|
||||
```
|
||||
Next Service Date = service date + serviceIntervalDays (Excel: =A+365)
|
||||
@@ -27,40 +102,19 @@ Next Service Km = service km + serviceIntervalKm (Excel: =B+15000)
|
||||
|
||||
These come back on each service record as `nextServiceDate` / `nextServiceKm`.
|
||||
|
||||
## Auth & access control
|
||||
|
||||
All endpoints except `/api/health` and `/api/auth/login` require a bearer token.
|
||||
Login verifies credentials against the PocketBase `users` collection, then the
|
||||
API Server issues its own HS256 JWT (valid 7 days).
|
||||
|
||||
- **Sessions** — each login also creates a `sessions` record (device label, IP,
|
||||
user-agent, expiry) whose id is embedded as the JWT `jti`. `withAuth` rejects
|
||||
any token whose session is missing or revoked, which powers the "active
|
||||
sessions" list and remote logout in Settings. (Any JWT minted before sessions
|
||||
were introduced has no `jti` and is treated as revoked.)
|
||||
- **Per-user car ownership + sharing** — cars are not a global list. `cars.owner`
|
||||
marks ownership and `car_shares` grants other users `read` or `write` access.
|
||||
Every car/service/part handler is gated by `requireCarAccess`:
|
||||
- **read** — view the car, its service records and parts.
|
||||
- **write** — edit the car and full service/part CRUD.
|
||||
- **owner only** — delete the car and manage its shares.
|
||||
- **Admin role** — `users.role` (`user` | `admin`), embedded in the JWT and
|
||||
re-checked from PocketBase on each admin call (so demotion is immediate).
|
||||
Admins manage users under `/api/admin/*`. Guards prevent deleting your own
|
||||
account or removing/demoting the last admin.
|
||||
|
||||
```
|
||||
POST /api/auth/login { "email": "...", "password": "..." } -> { token, user }
|
||||
GET /api/auth/me (Authorization: Bearer <token>) -> { id, email, name, role }
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
```
|
||||
# public
|
||||
GET /healthz
|
||||
GET /api/health
|
||||
|
||||
GET /api/status # health of PocketBase + Web App, probed server-side
|
||||
POST /api/auth/login
|
||||
GET /api/auth/validate
|
||||
|
||||
# identity
|
||||
GET /api/auth/me
|
||||
GET /api/identity
|
||||
|
||||
# current user (profile / appearance / avatar / data / account lifecycle)
|
||||
GET /api/me PATCH /api/me
|
||||
@@ -70,14 +124,18 @@ POST /api/me/verify/request
|
||||
GET /api/me/export POST /api/me/import
|
||||
POST /api/me/delete POST /api/me/delete/cancel DELETE /api/me
|
||||
|
||||
# active sessions
|
||||
GET /api/sessions
|
||||
DELETE /api/sessions/{id} DELETE /api/sessions (revoke all others)
|
||||
# users + organizations (admin or superadmin; org writes are superadmin-only)
|
||||
GET /api/users POST /api/users
|
||||
PATCH /api/users/{id} DELETE /api/users/{id}
|
||||
GET /api/orgs POST /api/orgs
|
||||
PATCH /api/orgs/{id} DELETE /api/orgs/{id}
|
||||
|
||||
# admin (admin role required)
|
||||
GET /api/admin/users POST /api/admin/users
|
||||
PATCH /api/admin/users/{id} POST /api/admin/users/{id}/password
|
||||
DELETE /api/admin/users/{id}
|
||||
# superadmin
|
||||
GET /api/admin/pb-config PUT /api/admin/pb-config
|
||||
POST /api/admin/pb-config/test
|
||||
GET /api/admin/plugins POST /api/admin/plugins
|
||||
GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name}
|
||||
DELETE /api/admin/plugins/{name} POST /api/admin/plugins/{name}/health
|
||||
|
||||
# cars + sharing
|
||||
GET /api/cars POST /api/cars
|
||||
@@ -103,51 +161,81 @@ annotated with an `access` field. `GET /api/service-records?car={id}` and
|
||||
> get blanked. (The phone's odometer quick-edit sends the whole car for this
|
||||
> reason.)
|
||||
|
||||
## Layout
|
||||
## The panel (`/`)
|
||||
|
||||
A Vue 3 + Tailwind app (source in `panel/`, built into `internal/api/dist` and
|
||||
embedded at compile time). It is the superadmin console:
|
||||
|
||||
- **Overview** — live health of the API Server, PocketBase and the Web App.
|
||||
- **Users / Organizations** — full management, scoped to the caller's role.
|
||||
- **PocketBase** — retarget the database connection at runtime; test before
|
||||
saving. Applied immediately **and** persisted to `.env`, so it survives a
|
||||
restart. This is the escape hatch when the configured PocketBase is wrong or
|
||||
unreachable — the settings endpoints deliberately do **not** require a working
|
||||
service account.
|
||||
- **Plugins** — enable/disable/configure integrations, run health checks,
|
||||
register external plugins.
|
||||
- **API** — the endpoint reference.
|
||||
|
||||
Log in with any DriverVault account; the sections you see depend on your role.
|
||||
|
||||
```powershell
|
||||
cd panel
|
||||
npm install
|
||||
npm run dev # localhost:5174, proxies /api to localhost:8080
|
||||
npm run build # -> ../internal/api/dist (then rebuild the Go binary)
|
||||
```
|
||||
main.go
|
||||
internal/
|
||||
├── api/ # HTTP handlers + router (server.go)
|
||||
│ ├── auth.go services.go records.go parts.go cars.go
|
||||
│ ├── me.go sessions.go shares.go admin.go
|
||||
├── auth/jwt.go # HS256 JWT mint/verify
|
||||
├── config/config.go
|
||||
├── models/models.go
|
||||
└── pb/client.go # PocketBase superuser client
|
||||
scripts/ # Node/Python maintenance scripts (see below)
|
||||
bin/api-server.exe # prebuilt binary the deployment runs
|
||||
```
|
||||
|
||||
Editing panel source alone does nothing to the served panel — run `npm run build`
|
||||
and then rebuild the Go binary, since `dist` is embedded.
|
||||
|
||||
## Plugins
|
||||
|
||||
An extension system for integrating third-party services, managed by a
|
||||
superadmin. Two kinds share one contract: **built-in** (Go, compiled in) and
|
||||
**external** (any HTTP service, registered at runtime, **no rebuild**). State
|
||||
persists to `plugins.json`.
|
||||
|
||||
See **[`internal/plugins/README.md`](internal/plugins/README.md)** for the full
|
||||
guide. DriverVault ships no built-in connectors yet; the external kind is the
|
||||
place to start.
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and fill in:
|
||||
Copy `.env.example` to `.env` and fill in. Summary:
|
||||
|
||||
```
|
||||
PORT=8080
|
||||
PB_URL=http://10.2.1.10:8027
|
||||
PB_ADMIN_EMAIL=...
|
||||
PB_ADMIN_PASSWORD=...
|
||||
AUTH_SECRET=<long random value>
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
```
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `API_ADDR` | `:8080` | listen address (bare port accepted) |
|
||||
| `POCKETBASE_URL` | `http://10.2.1.10:8027` | PocketBase base URL |
|
||||
| `POCKETBASE_ADMIN_EMAIL` / `_PASSWORD` | — | superuser service account |
|
||||
| `CORS_ALLOW_ORIGINS` | `*` | comma-separated browser origins |
|
||||
| `WEBAPP_URL` | `http://localhost:5173` | probed by `/api/status` |
|
||||
| `AUTH_USERS_COLLECTION` | `users` | PocketBase auth collection |
|
||||
| `PLUGINS_FILE` | `plugins.json` | plugin state store |
|
||||
|
||||
`CORS_ORIGINS` only matters for **browser** clients (the web app). Native mobile
|
||||
apps are not subject to CORS. Set `AUTH_SECRET` to a long random value — the
|
||||
server warns and falls back to an insecure dev secret if it is unset.
|
||||
`PB_URL`, `PB_ADMIN_EMAIL`, `PB_ADMIN_PASSWORD`, `PORT` and `CORS_ORIGINS` are
|
||||
still honoured for older deployments; the modern names win when both are set.
|
||||
|
||||
`CORS_ALLOW_ORIGINS` only matters for **browser** clients (the web app). Native
|
||||
mobile apps are not subject to CORS.
|
||||
|
||||
The service account is **optional at startup**: without it the server still runs
|
||||
and a superadmin can log in and configure it from the panel, while management
|
||||
endpoints return 503.
|
||||
|
||||
## Scripts (`scripts/`)
|
||||
|
||||
```powershell
|
||||
node scripts/setup-pocketbase.mjs # create/reconcile collections (idempotent)
|
||||
node scripts/create-user.mjs <email> <pw> "Name" # create an app login
|
||||
node scripts/set-role.mjs <email> user|admin # promote/demote
|
||||
node scripts/backfill-car-owners.mjs # one-off: assign owner to legacy cars
|
||||
node scripts/setup-pocketbase.mjs # create/reconcile collections (idempotent)
|
||||
node scripts/create-user.mjs <email> <pw> "Name" # create an app login
|
||||
node scripts/set-role.mjs <email> user|admin|superadmin
|
||||
node scripts/backfill-car-owners.mjs # one-off: assign owner to legacy cars
|
||||
python scripts/seed_from_excel.py "C:/Users/jania/Desktop/Car Service.xlsx"
|
||||
```
|
||||
|
||||
The Node scripts read `PB_URL` / `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD` from the
|
||||
environment (or `.env`).
|
||||
The Node scripts read `POCKETBASE_URL` / `POCKETBASE_ADMIN_EMAIL` /
|
||||
`POCKETBASE_ADMIN_PASSWORD` (or the legacy `PB_*` names) from the environment.
|
||||
|
||||
> **PocketBase note:** collections created by the setup script do **not** get
|
||||
> automatic `created`/`updated` autodate fields in this PocketBase version —
|
||||
@@ -157,23 +245,36 @@ environment (or `.env`).
|
||||
|
||||
## First-time setup
|
||||
|
||||
1. **Create the PocketBase collections** (idempotent):
|
||||
1. **Create the PocketBase collections** (idempotent — safe to re-run on an
|
||||
existing deployment; it adds `organizations`, `users.organization`, and grows
|
||||
`users.role` to include `superadmin`):
|
||||
|
||||
```powershell
|
||||
$env:PB_URL="http://10.2.1.10:8027"
|
||||
$env:PB_ADMIN_EMAIL="you@example.com"
|
||||
$env:PB_ADMIN_PASSWORD="secret"
|
||||
$env:POCKETBASE_URL="http://10.2.1.10:8027"
|
||||
$env:POCKETBASE_ADMIN_EMAIL="you@example.com"
|
||||
$env:POCKETBASE_ADMIN_PASSWORD="secret"
|
||||
node scripts/setup-pocketbase.mjs
|
||||
```
|
||||
|
||||
2. **Run the server** — `go run .`, or build and run the binary (below).
|
||||
2. **Mint the first superadmin.** The panel's management screens need one, and
|
||||
only a superadmin can promote another — so the first has to come from the
|
||||
script:
|
||||
|
||||
3. **(Optional) Seed from the spreadsheet** with the server running (see scripts).
|
||||
```powershell
|
||||
node scripts/create-user.mjs admin@example.com "a-long-password" "Admin"
|
||||
node scripts/set-role.mjs admin@example.com superadmin
|
||||
```
|
||||
|
||||
3. **Run the server** — `go run ./cmd/server`, or build and run the binary.
|
||||
|
||||
4. **Open the panel** at `http://localhost:8080/` and sign in.
|
||||
|
||||
5. **(Optional) Seed from the spreadsheet** with the server running (see scripts).
|
||||
|
||||
## Build & run
|
||||
|
||||
```powershell
|
||||
go build -o bin/api-server.exe .
|
||||
go build -o bin/api-server.exe ./cmd/server
|
||||
```
|
||||
|
||||
The deployment runs the **prebuilt binary** `bin/api-server.exe` (not `go run`),
|
||||
@@ -188,3 +289,24 @@ Start-Process -FilePath ".\bin\api-server.exe" -WorkingDirectory "." `
|
||||
Go's `log` package writes to **stderr**, so check `api-server.err.log` for
|
||||
request logs and errors. After editing any Go source, rebuild and restart the
|
||||
process — editing source alone does nothing until the binary is rebuilt.
|
||||
|
||||
## Migrating from the JWT build
|
||||
|
||||
Earlier builds minted their own HS256 JWT and tracked a `sessions` collection
|
||||
for "active devices" / remote logout. That is gone — the server now relays
|
||||
PocketBase tokens. Consequences:
|
||||
|
||||
- **`AUTH_SECRET` is obsolete** and ignored.
|
||||
- **Login returns PocketBase's envelope** — `{token, record}`, not `{token, user}`.
|
||||
- **`GET|DELETE /api/sessions*` are gone.** PocketBase tokens are stateless, so
|
||||
there is nothing to revoke per-device. To lock every device out of an account,
|
||||
change its password: PocketBase rotates the user's token key, which
|
||||
invalidates every token already issued.
|
||||
- **User management moved** from `/api/admin/users*` to `/api/users*`, and the
|
||||
separate password-reset endpoint folded into `PATCH /api/users/{id}`
|
||||
(`{"password": "..."}`). Responses are enveloped: `{users}` / `{user}`.
|
||||
- **Existing tokens are invalid** — every client must log in once more.
|
||||
- The `sessions` collection is left in PocketBase rather than dropped; delete it
|
||||
by hand if you want it gone.
|
||||
|
||||
The Web App and Phone App in this repo are already updated for all of the above.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Command server runs the DriverVault API Server. It is the single gateway
|
||||
// between clients (web app, phone app, Home Assistant plugin, ESP32 device) and
|
||||
// the PocketBase database kept behind it — clients never talk to PocketBase
|
||||
// directly. It also serves the superadmin web panel at the server root.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/api"
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("[api] ")
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
client := pb.New(cfg.PocketBaseURL, cfg.PocketBaseAdminEmail, cfg.PocketBaseAdminPassword)
|
||||
|
||||
// Authenticate the service account up front so the first request doesn't pay
|
||||
// for it. A failure is NOT fatal: the PocketBase connection is editable at
|
||||
// runtime from the panel, so a superadmin must be able to log in and fix a
|
||||
// bad address or bad credentials.
|
||||
if cfg.AdminConfigured() {
|
||||
authCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := client.Authenticate(authCtx); err != nil {
|
||||
log.Printf("WARNING: PocketBase service account auth failed (%s): %v", cfg.PocketBaseURL, err)
|
||||
log.Printf("fix it under Settings → PocketBase in the panel at %s", cfg.Addr)
|
||||
} else {
|
||||
log.Printf("authenticated to PocketBase at %s", cfg.PocketBaseURL)
|
||||
}
|
||||
cancel()
|
||||
} else {
|
||||
log.Println("WARNING: POCKETBASE_ADMIN_EMAIL/PASSWORD unset — management endpoints return 503 until configured")
|
||||
}
|
||||
|
||||
srv := api.New(cfg, client)
|
||||
|
||||
if err := srv.StartPlugins(); err != nil {
|
||||
log.Printf("plugins: load failed: %v", err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("listening on %s (PocketBase: %s, panel at /)", cfg.Addr, cfg.PocketBaseURL)
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Graceful shutdown on SIGINT/SIGTERM.
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Println("shutting down...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
srv.Stop(ctx)
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown error: %v", err)
|
||||
}
|
||||
log.Println("stopped")
|
||||
}
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
module carcontrol/api
|
||||
module drivervault/apiserver
|
||||
|
||||
go 1.22
|
||||
go 1.26
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// adminUser is the API shape returned by the admin user-management endpoints.
|
||||
type adminUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
func (rec userRecord) toAdminUser() adminUser {
|
||||
return adminUser{
|
||||
ID: rec.ID,
|
||||
Email: rec.Email,
|
||||
Name: rec.Name,
|
||||
Role: orDefault(rec.Role, "user"),
|
||||
Verified: rec.Verified,
|
||||
Created: rec.Created,
|
||||
}
|
||||
}
|
||||
|
||||
// requireAdmin ensures the current request is from an admin. It re-reads the
|
||||
// user's role from PocketBase (rather than trusting the token) so a demotion
|
||||
// takes effect immediately. On failure it writes the response and returns false.
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
me, err := s.fetchUser(r, s.currentUserID(r))
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return false
|
||||
}
|
||||
if orDefault(me.Role, "user") != "admin" {
|
||||
writeError(w, http.StatusForbidden, "admin access required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// countAdmins returns how many users currently hold the admin role. Used to
|
||||
// prevent removing the last admin (which would lock everyone out of admin).
|
||||
func (s *Server) countAdmins(ctx context.Context) (int, error) {
|
||||
res, err := s.pb.List(ctx, s.usersCollection, url.Values{
|
||||
"filter": {"role='admin'"},
|
||||
"perPage": {"1"},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.TotalItems, nil
|
||||
}
|
||||
|
||||
// handleListUsers serves GET /api/admin/users.
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
res, err := s.pb.List(r.Context(), s.usersCollection, url.Values{
|
||||
"sort": {"email"},
|
||||
"perPage": {"500"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []userRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]adminUser, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, rec.toAdminUser())
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// handleCreateUser serves POST /api/admin/users.
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var in createUserRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
in.Email = strings.TrimSpace(strings.ToLower(in.Email))
|
||||
if in.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "email is required")
|
||||
return
|
||||
}
|
||||
if len(in.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
role := orDefault(in.Role, "user")
|
||||
if role != "user" && role != "admin" {
|
||||
writeError(w, http.StatusBadRequest, "role must be 'user' or 'admin'")
|
||||
return
|
||||
}
|
||||
name := in.Name
|
||||
if name == "" {
|
||||
name = strings.SplitN(in.Email, "@", 2)[0]
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"email": in.Email,
|
||||
"password": in.Password,
|
||||
"passwordConfirm": in.Password,
|
||||
"name": name,
|
||||
"role": role,
|
||||
"emailVisibility": true,
|
||||
"verified": true,
|
||||
}
|
||||
var rec userRecord
|
||||
if err := s.pb.Create(r.Context(), s.usersCollection, payload, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rec.toAdminUser())
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Role *string `json:"role"`
|
||||
}
|
||||
|
||||
// handleUpdateUser serves PATCH /api/admin/users/{id} (name and/or role).
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
var in updateUserRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{}
|
||||
if in.Name != nil {
|
||||
payload["name"] = *in.Name
|
||||
}
|
||||
if in.Role != nil {
|
||||
role := *in.Role
|
||||
if role != "user" && role != "admin" {
|
||||
writeError(w, http.StatusBadRequest, "role must be 'user' or 'admin'")
|
||||
return
|
||||
}
|
||||
// Guard: don't demote the last remaining admin.
|
||||
if role != "admin" {
|
||||
if blocked, err := s.wouldRemoveLastAdmin(r, id); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
} else if blocked {
|
||||
writeError(w, http.StatusBadRequest, "cannot demote the last admin")
|
||||
return
|
||||
}
|
||||
}
|
||||
payload["role"] = role
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "nothing to update")
|
||||
return
|
||||
}
|
||||
|
||||
var rec userRecord
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, id, payload, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rec.toAdminUser())
|
||||
}
|
||||
|
||||
type setPasswordRequest struct {
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
// handleSetUserPassword serves POST /api/admin/users/{id}/password.
|
||||
func (s *Server) handleSetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var in setPasswordRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if len(in.NewPassword) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"password": in.NewPassword,
|
||||
"passwordConfirm": in.NewPassword,
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, r.PathValue("id"), payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDeleteUser serves DELETE /api/admin/users/{id}.
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if id == s.currentUserID(r) {
|
||||
writeError(w, http.StatusBadRequest, "you cannot delete your own account here")
|
||||
return
|
||||
}
|
||||
// Guard: don't delete the last remaining admin.
|
||||
if blocked, err := s.wouldRemoveLastAdmin(r, id); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
} else if blocked {
|
||||
writeError(w, http.StatusBadRequest, "cannot delete the last admin")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), s.usersCollection, id); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// wouldRemoveLastAdmin reports whether removing/demoting user `id` would leave
|
||||
// zero admins (i.e. `id` is currently an admin and is the only one).
|
||||
func (s *Server) wouldRemoveLastAdmin(r *http.Request, id string) (bool, error) {
|
||||
target, err := s.fetchUser(r, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if orDefault(target.Role, "user") != "admin" {
|
||||
return false, nil // not an admin; removing them changes nothing
|
||||
}
|
||||
count, err := s.countAdmins(r.Context())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count <= 1, nil
|
||||
}
|
||||
+239
-225
@@ -3,30 +3,117 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/auth"
|
||||
)
|
||||
|
||||
// tokenTTL is how long an issued login token stays valid.
|
||||
const tokenTTL = 7 * 24 * time.Hour
|
||||
// Role names as stored in the PocketBase users.role select field. A missing or
|
||||
// empty value is treated as roleUser.
|
||||
const (
|
||||
roleUser = "user"
|
||||
roleAdmin = "admin"
|
||||
roleSuperadmin = "superadmin"
|
||||
)
|
||||
|
||||
// publicPaths bypass authentication. Everything else requires a valid token.
|
||||
// publicPaths bypass authentication. Everything else under /api/ requires a
|
||||
// valid PocketBase token.
|
||||
var publicPaths = map[string]bool{
|
||||
"/api/health": true,
|
||||
"/api/auth/login": true,
|
||||
"/api/health": true,
|
||||
"/api/status": true,
|
||||
"/api/auth/login": true,
|
||||
"/api/auth/validate": true,
|
||||
"/healthz": true,
|
||||
}
|
||||
|
||||
type ctxKey string
|
||||
type ctxKey int
|
||||
|
||||
const claimsKey ctxKey = "claims"
|
||||
const ctxCaller ctxKey = iota
|
||||
|
||||
// withAuth rejects requests to non-public paths that lack a valid bearer token.
|
||||
// callerIdentity is who the request token belongs to. It is resolved from
|
||||
// PocketBase on each request, so a role change or a deletion takes effect
|
||||
// immediately rather than lingering until a token expires.
|
||||
type callerIdentity struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
Role string
|
||||
OrgID string // organization record id ("" when the user belongs to no org)
|
||||
}
|
||||
|
||||
func (c *callerIdentity) isSuperadmin() bool { return c != nil && c.Role == roleSuperadmin }
|
||||
func (c *callerIdentity) isManager() bool {
|
||||
return c != nil && (c.Role == roleAdmin || c.Role == roleSuperadmin)
|
||||
}
|
||||
|
||||
// caller returns the identity stashed on the request context by withAuth.
|
||||
func caller(r *http.Request) *callerIdentity {
|
||||
if v, ok := r.Context().Value(ctxCaller).(*callerIdentity); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// currentUserID returns the authenticated user's id. Handlers on non-public
|
||||
// paths can treat "" as "not authenticated" — withAuth already rejected those.
|
||||
func (s *Server) currentUserID(r *http.Request) string {
|
||||
if who := caller(r); who != nil {
|
||||
return who.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// bearerToken extracts the caller's token, accepting both "Bearer <token>" and
|
||||
// a raw token (PocketBase's own SDKs send the latter).
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return strings.TrimSpace(h)
|
||||
}
|
||||
|
||||
// identify resolves the caller's id/email/name/role/org from their PocketBase
|
||||
// token by asking PocketBase to refresh it. A non-200 status means the token is
|
||||
// invalid or expired.
|
||||
func (s *Server) identify(ctx context.Context, token string) (*callerIdentity, int, error) {
|
||||
raw, status, err := s.pb.AuthRefresh(ctx, s.usersCollection(), token)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, status, nil
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Record struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Organization string `json:"organization"`
|
||||
} `json:"record"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, status, err
|
||||
}
|
||||
role := out.Record.Role
|
||||
if role == "" {
|
||||
role = roleUser
|
||||
}
|
||||
return &callerIdentity{
|
||||
ID: out.Record.ID,
|
||||
Email: out.Record.Email,
|
||||
Name: out.Record.Name,
|
||||
Role: role,
|
||||
OrgID: out.Record.Organization,
|
||||
}, http.StatusOK, nil
|
||||
}
|
||||
|
||||
// withAuth rejects requests to non-public /api/ paths that lack a valid
|
||||
// PocketBase token, and stashes the resolved identity on the request context.
|
||||
func (s *Server) withAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Public API paths, plus everything outside /api/ (the embedded web
|
||||
@@ -35,64 +122,155 @@ func (s *Server) withAuth(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing bearer token")
|
||||
who, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims, err := auth.Verify(s.authSecret, token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if s.sessionRevoked(r.Context(), claims.Jti) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who)))
|
||||
})
|
||||
}
|
||||
|
||||
// sessionRevoked reports whether the token's backing session is missing or
|
||||
// revoked (e.g. via "log out this device" from the settings panel). Tokens
|
||||
// minted before session-tracking existed have no jti and are rejected too,
|
||||
// which simply forces one fresh login.
|
||||
func (s *Server) sessionRevoked(ctx context.Context, jti string) bool {
|
||||
if jti == "" {
|
||||
return true
|
||||
// authenticate resolves and validates the caller, writing the error response
|
||||
// itself and returning ok=false when the request should not proceed.
|
||||
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*callerIdentity, bool) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing bearer token")
|
||||
return nil, false
|
||||
}
|
||||
res, err := s.pb.List(ctx, colSessions, url.Values{
|
||||
"filter": {fmt.Sprintf("jti='%s'", jti)},
|
||||
"perPage": {"1"},
|
||||
})
|
||||
who, status, err := s.identify(r.Context(), token)
|
||||
if err != nil {
|
||||
return true
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
var recs []sessionRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 {
|
||||
return true
|
||||
if status != http.StatusOK || who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return nil, false
|
||||
}
|
||||
return recs[0].Revoked
|
||||
return who, true
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
// requireRole is the shared gate for privileged handlers: it needs the service
|
||||
// account (every privileged flow runs through it) and a caller satisfying ok.
|
||||
// withAuth has already established the identity.
|
||||
func (s *Server) requireRole(next http.HandlerFunc, ok func(*callerIdentity) bool, denied string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.pb.Configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "user management not configured on the server")
|
||||
return
|
||||
}
|
||||
if !ok(caller(r)) {
|
||||
writeError(w, http.StatusForbidden, denied)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
// Accept both "Bearer <token>" and a raw token.
|
||||
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return strings.TrimSpace(h)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
// requireManager wraps a handler so only managers (admin or superadmin) proceed.
|
||||
func (s *Server) requireManager(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireRole(next, func(c *callerIdentity) bool { return c.isManager() }, "admin role required")
|
||||
}
|
||||
|
||||
// requireSuperadmin wraps a handler so only superadmins proceed.
|
||||
func (s *Server) requireSuperadmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireRole(next, func(c *callerIdentity) bool { return c.isSuperadmin() }, "superadmin role required")
|
||||
}
|
||||
|
||||
// requireSuperadminAuth gates a handler on a superadmin caller WITHOUT requiring
|
||||
// the service account to already be configured. Used by the PocketBase settings
|
||||
// endpoints, whose whole purpose is to configure that service account.
|
||||
func (s *Server) requireSuperadminAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !caller(r).isSuperadmin() {
|
||||
writeError(w, http.StatusForbidden, "superadmin role required")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
// Body: {"email"|"identity":"...","password":"..."}
|
||||
// Proxies to the PocketBase users auth-with-password and relays its response —
|
||||
// the client gets PocketBase's own token and user record. PocketBase's address
|
||||
// lives only in this server and is never exposed to clients.
|
||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Identity string `json:"identity"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
identity := body.Identity
|
||||
if identity == "" {
|
||||
identity = body.Email
|
||||
}
|
||||
if identity == "" || body.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
raw, status, err := s.pb.LoginWithPassword(r.Context(), s.usersCollection(), identity, body.Password)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
relay(w, status, raw)
|
||||
}
|
||||
|
||||
// GET /api/auth/validate (Authorization: <pb token>)
|
||||
// Proxies to PocketBase auth-refresh to confirm a token is still valid.
|
||||
func (s *Server) handleAuthValidate(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"valid": false})
|
||||
return
|
||||
}
|
||||
raw, status, err := s.pb.AuthRefresh(r.Context(), s.usersCollection(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"valid": false, "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
relay(w, status, raw)
|
||||
}
|
||||
|
||||
// GET /api/auth/me — the caller's identity, from their token.
|
||||
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, userInfo{ID: who.ID, Email: who.Email, Name: who.Name, Role: who.Role})
|
||||
}
|
||||
|
||||
// GET /api/identity — like /api/auth/me, plus the caller's organization. The
|
||||
// panel uses this to decide which management cards to show.
|
||||
func (s *Server) handleIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
orgName := ""
|
||||
if who.OrgID != "" {
|
||||
orgName = s.orgName(r.Context(), who.OrgID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": who.ID,
|
||||
"email": who.Email,
|
||||
"name": who.Name,
|
||||
"role": who.Role,
|
||||
"organization": who.OrgID,
|
||||
"organizationName": orgName,
|
||||
})
|
||||
}
|
||||
|
||||
// userInfo is the compact identity shape returned by /api/auth/me.
|
||||
type userInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -100,173 +278,9 @@ type userInfo struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User userInfo `json:"user"`
|
||||
}
|
||||
|
||||
// handleLogin verifies credentials against the PocketBase users collection and,
|
||||
// on success, mints an API Server JWT.
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var in loginRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Email == "" || in.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection, in.Email, in.Password)
|
||||
if err != nil {
|
||||
// Don't leak whether it was the email or the password.
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// The auth record doesn't carry the role field; fetch it so the token and
|
||||
// login response reflect the user's access role. Default to "user".
|
||||
role := "user"
|
||||
if u, err := s.fetchUser(r, rec.ID); err == nil {
|
||||
role = orDefault(u.Role, "user")
|
||||
}
|
||||
|
||||
jti, err := auth.NewJTI()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not issue token")
|
||||
return
|
||||
}
|
||||
if err := s.createSession(r.Context(), rec.ID, jti, r); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create session")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.Sign(s.authSecret, auth.Claims{
|
||||
Sub: rec.ID,
|
||||
Email: rec.Email,
|
||||
Name: rec.Name,
|
||||
Role: role,
|
||||
Jti: jti,
|
||||
}, tokenTTL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not issue token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: token,
|
||||
User: userInfo{ID: rec.ID, Email: rec.Email, Name: rec.Name, Role: role},
|
||||
})
|
||||
}
|
||||
|
||||
// sessionRecord is one row of the "sessions" collection — a login token's
|
||||
// device/revocation record, used to power "active sessions" in the settings
|
||||
// panel and to let a user log a device out remotely.
|
||||
type sessionRecord struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Jti string `json:"jti"`
|
||||
DeviceLabel string `json:"device_label"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Revoked bool `json:"revoked"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (s *Server) createSession(ctx context.Context, userID, jti string, r *http.Request) error {
|
||||
payload := map[string]any{
|
||||
"user": userID,
|
||||
"jti": jti,
|
||||
"device_label": deviceLabelFromUA(r.UserAgent()),
|
||||
"ip": clientIP(r),
|
||||
"user_agent": r.UserAgent(),
|
||||
"revoked": false,
|
||||
"expires_at": time.Now().Add(tokenTTL).UTC().Format(time.RFC3339),
|
||||
}
|
||||
return s.pb.Create(ctx, colSessions, payload, nil)
|
||||
}
|
||||
|
||||
// clientIP prefers a forwarding header (in case of a future reverse proxy)
|
||||
// and otherwise strips the port from the raw remote address.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
|
||||
return strings.TrimSpace(strings.Split(xf, ",")[0])
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// deviceLabelFromUA turns a User-Agent header into a short human label like
|
||||
// "Chrome on Windows" for the active-sessions list. Best-effort only.
|
||||
func deviceLabelFromUA(ua string) string {
|
||||
if ua == "" {
|
||||
return "Unknown device"
|
||||
}
|
||||
|
||||
var os string
|
||||
switch {
|
||||
case strings.Contains(ua, "Android"):
|
||||
os = "Android"
|
||||
case strings.Contains(ua, "iPhone"), strings.Contains(ua, "iPad"):
|
||||
os = "iOS"
|
||||
case strings.Contains(ua, "Windows"):
|
||||
os = "Windows"
|
||||
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
|
||||
os = "macOS"
|
||||
case strings.Contains(ua, "Linux"):
|
||||
os = "Linux"
|
||||
}
|
||||
|
||||
var app string
|
||||
switch {
|
||||
case strings.Contains(ua, "Edg/"):
|
||||
app = "Edge"
|
||||
case strings.Contains(ua, "Chrome/"):
|
||||
app = "Chrome"
|
||||
case strings.Contains(ua, "Firefox/"):
|
||||
app = "Firefox"
|
||||
case strings.Contains(ua, "Safari/") && !strings.Contains(ua, "Chrome"):
|
||||
app = "Safari"
|
||||
case strings.Contains(ua, "Dart") || strings.Contains(ua, "okhttp"):
|
||||
app = "Car Control app"
|
||||
}
|
||||
|
||||
switch {
|
||||
case app != "" && os != "":
|
||||
return app + " on " + os
|
||||
case app != "":
|
||||
return app
|
||||
case os != "":
|
||||
return os
|
||||
case len(ua) > 60:
|
||||
return ua[:60]
|
||||
default:
|
||||
return ua
|
||||
}
|
||||
}
|
||||
|
||||
// currentUserID returns the authenticated user's id from the request context.
|
||||
// Returns "" only if called on an unauthenticated request (withAuth already
|
||||
// guards every non-public path, so handlers can treat "" as "not authenticated").
|
||||
func (s *Server) currentUserID(r *http.Request) string {
|
||||
if claims, ok := r.Context().Value(claimsKey).(*auth.Claims); ok {
|
||||
return claims.Sub
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleMe returns the authenticated user from the token claims.
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, userInfo{ID: claims.Sub, Email: claims.Email, Name: claims.Name, Role: claims.Role})
|
||||
// relay copies an upstream PocketBase status + JSON body to the client.
|
||||
func relay(w http.ResponseWriter, status int, body []byte) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"carcontrol/api/internal/models"
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// Access levels a user can have on a car. accessNone means no access at all.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,8 +6,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<title>DriverVault · API Server</title>
|
||||
<script type="module" crossorigin src="/assets/index-o_I931vi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DfVJ8vbT.css">
|
||||
<script type="module" crossorigin src="/assets/index-DKHgRvVM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-i1JZk1ZM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleHealth is the liveness probe. It reports only on this process — it does
|
||||
// not touch PocketBase, so it stays fast and stays "ok" even while a dependency
|
||||
// is down. Use /api/status for dependency health.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"service": "drivervault-api",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/auth"
|
||||
"carcontrol/api/internal/models"
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// deletionCooldown is how long an account-deletion request sits before it can
|
||||
@@ -65,19 +64,19 @@ func orDefault(v, fallback string) string {
|
||||
|
||||
func (s *Server) fetchUser(r *http.Request, id string) (*userRecord, error) {
|
||||
var rec userRecord
|
||||
if err := s.pb.GetOne(r.Context(), s.usersCollection, id, &rec); err != nil {
|
||||
if err := s.pb.GetOne(r.Context(), s.usersCollection(), id, &rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
rec, err := s.fetchUser(r, claims.Sub)
|
||||
rec, err := s.fetchUser(r, claims.ID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
@@ -102,8 +101,8 @@ var validFontSizes = map[string]bool{"small": true, "medium": true, "large": tru
|
||||
// body are touched, so the Account/Profile/Appearance sections of the settings
|
||||
// panel can each save independently without clobbering the others.
|
||||
func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
@@ -146,7 +145,7 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var rec userRecord
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, &rec); err != nil {
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -159,8 +158,8 @@ type changePasswordRequest struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
@@ -180,13 +179,13 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Verify the current password the same way login does, since the API
|
||||
// Server otherwise only ever talks to PocketBase as a superuser.
|
||||
if _, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection, claims.Email, in.OldPassword); err != nil {
|
||||
if _, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection(), claims.Email, in.OldPassword); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "current password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{"password": in.NewPassword, "passwordConfirm": in.NewPassword}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil {
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -194,8 +193,8 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
@@ -216,11 +215,11 @@ func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection, claims.Sub, nil, "avatar", header.Filename, data); err != nil {
|
||||
if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection(), claims.ID, nil, "avatar", header.Filename, data); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
rec, err := s.fetchUser(r, claims.Sub)
|
||||
rec, err := s.fetchUser(r, claims.ID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
@@ -229,12 +228,12 @@ func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, map[string]any{"avatar": ""}, nil); err != nil {
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, map[string]any{"avatar": ""}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -242,12 +241,12 @@ func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
rec, err := s.fetchUser(r, claims.Sub)
|
||||
rec, err := s.fetchUser(r, claims.ID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
@@ -256,7 +255,7 @@ func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "no avatar set")
|
||||
return
|
||||
}
|
||||
data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection, rec.ID, rec.Avatar)
|
||||
data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection(), rec.ID, rec.Avatar)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
@@ -267,12 +266,12 @@ func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if err := s.pb.RequestVerification(r.Context(), s.usersCollection, claims.Email); err != nil {
|
||||
if err := s.pb.RequestVerification(r.Context(), s.usersCollection(), claims.Email); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -283,19 +282,19 @@ func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Reques
|
||||
// (with its service records and parts) into one downloadable JSON file. Cars
|
||||
// merely shared with the user are not exported — only cars they own.
|
||||
func (s *Server) handleExportData(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
user, err := s.fetchUser(r, claims.Sub)
|
||||
user, err := s.fetchUser(r, claims.ID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
carsRes, err := s.pb.List(r.Context(), colCars, url.Values{
|
||||
"filter": {fmt.Sprintf("owner='%s'", claims.Sub)},
|
||||
"filter": {fmt.Sprintf("owner='%s'", claims.ID)},
|
||||
"sort": {"name"},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
@@ -396,8 +395,8 @@ type importResult struct {
|
||||
// export's "account"/"exportedAt"), since round-tripping the exact export
|
||||
// file is the main use case.
|
||||
func (s *Server) handleImportData(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
@@ -423,7 +422,7 @@ func (s *Server) handleImportData(w http.ResponseWriter, r *http.Request) {
|
||||
// Imported cars are owned by the importing user, regardless of any
|
||||
// owner in the file.
|
||||
payload := carPayload(car)
|
||||
payload["owner"] = claims.Sub
|
||||
payload["owner"] = claims.ID
|
||||
|
||||
var rec carRecord
|
||||
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
|
||||
@@ -460,8 +459,8 @@ type deleteAccountRequest struct {
|
||||
// handleRequestDeletion starts the cooldown. The account is not touched yet —
|
||||
// handleFinalizeDeletion is a separate, later call once the cooldown elapses.
|
||||
func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
@@ -477,7 +476,7 @@ func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
now := time.Now().UTC()
|
||||
payload := map[string]any{"deletion_requested_at": formatPBDate(now)}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil {
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -488,13 +487,13 @@ func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{"deletion_requested_at": ""}
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil {
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -507,12 +506,12 @@ func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
// shared cars/service-records/parts data is untouched, since it belongs to
|
||||
// the household, not to one account.
|
||||
func (s *Server) handleFinalizeDeletion(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
claims := caller(r)
|
||||
if claims == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
rec, err := s.fetchUser(r, claims.Sub)
|
||||
rec, err := s.fetchUser(r, claims.ID)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
@@ -526,7 +525,7 @@ func (s *Server) handleFinalizeDeletion(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusForbidden, "the cooldown period has not elapsed yet")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Delete(r.Context(), s.usersCollection, claims.Sub); err != nil {
|
||||
if err := s.pb.Delete(r.Context(), s.usersCollection(), claims.ID); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// orgView is the trimmed organization shape returned to clients.
|
||||
type orgView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// orgNameMap returns an id→name map of all organizations via the service
|
||||
// account. On any error it returns an empty (non-nil) map so callers can index
|
||||
// it safely.
|
||||
func (s *Server) orgNameMap(ctx context.Context) map[string]string {
|
||||
out := map[string]string{}
|
||||
if !s.pb.Configured() {
|
||||
return out
|
||||
}
|
||||
data, status, err := s.pb.Raw(ctx, http.MethodGet,
|
||||
"/api/collections/"+colOrgs+"/records?perPage=500&fields=id,name", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return out
|
||||
}
|
||||
var list struct {
|
||||
Items []orgView `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &list)
|
||||
for _, o := range list.Items {
|
||||
out[o.ID] = o.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// orgName resolves a single organization's name (best effort; "" on miss).
|
||||
func (s *Server) orgName(ctx context.Context, id string) string {
|
||||
if id == "" || !s.pb.Configured() {
|
||||
return ""
|
||||
}
|
||||
data, status, err := s.pb.Raw(ctx, http.MethodGet,
|
||||
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(id)+"?fields=id,name", nil)
|
||||
if err != nil || status != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
var o orgView
|
||||
_ = json.Unmarshal(data, &o)
|
||||
return o.Name
|
||||
}
|
||||
|
||||
// GET /api/orgs — list organizations (manager only). Superadmins see all;
|
||||
// admins see only their own organization.
|
||||
func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
path := "/api/collections/" + colOrgs + "/records?perPage=500&sort=name&fields=id,name,created"
|
||||
if !who.isSuperadmin() {
|
||||
if who.OrgID == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"organizations": []orgView{}})
|
||||
return
|
||||
}
|
||||
path += "&filter=" + url.QueryEscape("id = \""+who.OrgID+"\"")
|
||||
}
|
||||
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relay(w, status, data)
|
||||
return
|
||||
}
|
||||
var list struct {
|
||||
Items []orgView `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &list)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"organizations": list.Items})
|
||||
}
|
||||
|
||||
// POST /api/orgs — create an organization (superadmin only). Body: {name}.
|
||||
func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := decodeOrgName(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, status, err := s.pb.Raw(r.Context(), http.MethodPost,
|
||||
"/api/collections/"+colOrgs+"/records", map[string]any{"name": name})
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
// Relay PocketBase's error (e.g. duplicate name violates the unique index).
|
||||
relay(w, status, data)
|
||||
return
|
||||
}
|
||||
var org orgView
|
||||
_ = json.Unmarshal(data, &org)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"organization": org})
|
||||
}
|
||||
|
||||
// PATCH /api/orgs/{id} — rename an organization (superadmin only). Body: {name}.
|
||||
func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing organization id")
|
||||
return
|
||||
}
|
||||
name, ok := decodeOrgName(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, status, err := s.pb.Raw(r.Context(), http.MethodPatch,
|
||||
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(id), map[string]any{"name": name})
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relay(w, status, data)
|
||||
return
|
||||
}
|
||||
var org orgView
|
||||
_ = json.Unmarshal(data, &org)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"organization": org})
|
||||
}
|
||||
|
||||
// DELETE /api/orgs/{id} — delete an organization (superadmin only). Refused
|
||||
// while the org still has members, to avoid silently orphaning users.
|
||||
func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing organization id")
|
||||
return
|
||||
}
|
||||
|
||||
// Guard: block deletion if any user still belongs to this org.
|
||||
countPath := "/api/collections/" + s.usersCollection() + "/records?perPage=1&fields=id&filter=" +
|
||||
url.QueryEscape("organization = \""+id+"\"")
|
||||
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, countPath, nil)
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
var page struct {
|
||||
TotalItems int `json:"totalItems"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &page)
|
||||
if page.TotalItems > 0 {
|
||||
writeError(w, http.StatusConflict, "organization still has members; reassign or remove them first")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, status, err = s.pb.Raw(r.Context(), http.MethodDelete,
|
||||
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(id), nil)
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK && status != http.StatusNoContent {
|
||||
relay(w, status, data)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// decodeOrgName parses and validates a {name} body, writing an error response
|
||||
// and returning ok=false on failure.
|
||||
func decodeOrgName(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return "", false
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "organization name is required")
|
||||
return "", false
|
||||
}
|
||||
if len(name) > 120 {
|
||||
writeError(w, http.StatusBadRequest, "organization name is too long (max 120)")
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"carcontrol/api/internal/models"
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
func (s *Server) listParts(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"drivervault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
// GET /api/admin/plugins — every known plugin (registry ∪ persisted), secrets masked.
|
||||
func (s *Server) handleListPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plugins": s.plugins.List()})
|
||||
}
|
||||
|
||||
// GET /api/admin/plugins/{name} — one plugin's view.
|
||||
func (s *Server) handleGetPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := s.plugins.Get(r.PathValue("name"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plugin": v})
|
||||
}
|
||||
|
||||
// PUT /api/admin/plugins/{name} — enable/disable + merge config. Body:
|
||||
// {enabled?, config?}. A secret left at the mask keeps its stored value.
|
||||
func (s *Server) handleUpdatePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
current, ok := s.plugins.Get(name)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
enabled := current.Enabled
|
||||
if body.Enabled != nil {
|
||||
enabled = *body.Enabled
|
||||
}
|
||||
|
||||
v, err := s.plugins.Upsert(r.Context(), name, enabled, body.Config)
|
||||
if err != nil {
|
||||
if plugins.IsUnknown(err) {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
// A failed init (e.g. bad credentials) is reported but the state was saved.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plugin": v, "warning": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"plugin": v})
|
||||
}
|
||||
|
||||
// POST /api/admin/plugins — register an external (remote HTTP) plugin. Body:
|
||||
// {name, baseURL, provider?}. This is the "add a plugin without a rebuild" path.
|
||||
func (s *Server) handleRegisterPlugin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseURL"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Name == "" || body.BaseURL == "" {
|
||||
writeError(w, http.StatusBadRequest, "name and baseURL are required")
|
||||
return
|
||||
}
|
||||
if err := s.plugins.RegisterExternal(body.Name, body.BaseURL, body.Provider); err != nil {
|
||||
writeError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
v, _ := s.plugins.Get(body.Name)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"plugin": v})
|
||||
}
|
||||
|
||||
// DELETE /api/admin/plugins/{name} — remove an external plugin (builtins can
|
||||
// only be disabled).
|
||||
func (s *Server) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.plugins.Remove(r.Context(), r.PathValue("name")); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// POST /api/admin/plugins/{name}/health — run a health check now. Works on
|
||||
// disabled plugins too, so a config can be verified before enabling it.
|
||||
func (s *Server) handlePluginHealth(w http.ResponseWriter, r *http.Request) {
|
||||
h, err := s.plugins.HealthCheck(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
if plugins.IsUnknown(err) {
|
||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/models"
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
// PocketBase stores datetimes as e.g. "2015-06-12 00:00:00.000Z". These layouts
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
// writeJSON writes v as a JSON response with the given status code.
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
log.Printf("writeJSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// errorBody is the standard error envelope.
|
||||
type errorBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// writeError writes a JSON error response.
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, errorBody{Error: msg})
|
||||
}
|
||||
|
||||
// writeUpstreamDown reports that PocketBase could not be reached at all (a
|
||||
// transport error, as opposed to PocketBase answering with an error status).
|
||||
func writeUpstreamDown(w http.ResponseWriter, err error) {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{
|
||||
"error": "cannot reach PocketBase",
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// writePBError maps a PocketBase error from the typed CRUD helpers to an
|
||||
// appropriate HTTP status.
|
||||
func writePBError(w http.ResponseWriter, err error) {
|
||||
if apiErr, ok := err.(*pb.APIError); ok {
|
||||
status := apiErr.Status
|
||||
if status < 400 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
writeError(w, status, apiErr.Body)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
}
|
||||
|
||||
// decodeJSON strictly decodes a request body, rejecting unknown fields.
|
||||
func decodeJSON(r *http.Request, dest any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dest)
|
||||
}
|
||||
+201
-121
@@ -1,58 +1,71 @@
|
||||
// Package api exposes the HTTP REST surface of the car-control API Server.
|
||||
// Package api exposes the HTTP REST surface of the DriverVault API Server.
|
||||
//
|
||||
// Clients (web app, phone app, ...) talk only to this server; this server is
|
||||
// the only thing that talks to PocketBase. Endpoints:
|
||||
// Clients (web app, phone app, Home Assistant plugin, ESP32 device) talk only to
|
||||
// this server; this server is the only thing that talks to PocketBase. It also
|
||||
// serves the superadmin web panel at the root.
|
||||
//
|
||||
// Authentication is PocketBase's own: /api/auth/login is proxied to the
|
||||
// PocketBase users collection and the client keeps the token PocketBase minted.
|
||||
// Every protected request re-resolves that token against PocketBase, so a role
|
||||
// change or a deletion takes effect immediately.
|
||||
//
|
||||
// # public
|
||||
// GET /healthz
|
||||
// GET /api/health
|
||||
// GET /api/cars
|
||||
// POST /api/cars
|
||||
// GET /api/cars/{id}
|
||||
// PATCH /api/cars/{id}
|
||||
// DELETE /api/cars/{id}
|
||||
// GET /api/status
|
||||
// POST /api/auth/login
|
||||
// GET /api/auth/validate
|
||||
//
|
||||
// # identity
|
||||
// GET /api/auth/me
|
||||
// GET /api/identity
|
||||
//
|
||||
// # current user
|
||||
// GET /api/me PATCH /api/me DELETE /api/me
|
||||
// POST /api/me/password
|
||||
// POST /api/me/avatar GET /api/me/avatar DELETE /api/me/avatar
|
||||
// POST /api/me/verify/request
|
||||
// GET /api/me/export POST /api/me/import
|
||||
// POST /api/me/delete POST /api/me/delete/cancel
|
||||
//
|
||||
// # users + organizations (manager; writes to orgs are superadmin-only)
|
||||
// GET /api/users POST /api/users
|
||||
// PATCH /api/users/{id} DELETE /api/users/{id}
|
||||
// GET /api/orgs POST /api/orgs
|
||||
// PATCH /api/orgs/{id} DELETE /api/orgs/{id}
|
||||
//
|
||||
// # superadmin
|
||||
// GET /api/admin/pb-config PUT /api/admin/pb-config
|
||||
// POST /api/admin/pb-config/test
|
||||
// GET /api/admin/plugins POST /api/admin/plugins
|
||||
// GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name}
|
||||
// DELETE /api/admin/plugins/{name} POST /api/admin/plugins/{name}/health
|
||||
//
|
||||
// # cars, service records, parts, shares
|
||||
// GET /api/cars POST /api/cars
|
||||
// GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
|
||||
// GET /api/cars/{id}/service-records
|
||||
// GET /api/cars/{id}/parts
|
||||
// GET /api/cars/{id}/shares
|
||||
// POST /api/cars/{id}/shares
|
||||
// GET /api/cars/{id}/shares POST /api/cars/{id}/shares
|
||||
// DELETE /api/cars/{id}/shares/{userId}
|
||||
// GET /api/service-records
|
||||
// POST /api/service-records
|
||||
// GET /api/service-records/{id}
|
||||
// PATCH /api/service-records/{id}
|
||||
// GET /api/service-records POST /api/service-records
|
||||
// GET /api/service-records/{id} PATCH /api/service-records/{id}
|
||||
// DELETE /api/service-records/{id}
|
||||
// GET /api/parts
|
||||
// POST /api/parts
|
||||
// GET /api/parts/{id}
|
||||
// PATCH /api/parts/{id}
|
||||
// DELETE /api/parts/{id}
|
||||
// GET /api/me
|
||||
// PATCH /api/me
|
||||
// DELETE /api/me
|
||||
// POST /api/me/password
|
||||
// POST /api/me/avatar
|
||||
// GET /api/me/avatar
|
||||
// DELETE /api/me/avatar
|
||||
// POST /api/me/verify/request
|
||||
// GET /api/me/export
|
||||
// POST /api/me/import
|
||||
// POST /api/me/delete
|
||||
// POST /api/me/delete/cancel
|
||||
// GET /api/sessions
|
||||
// DELETE /api/sessions/{id}
|
||||
// DELETE /api/sessions
|
||||
// GET /api/admin/users
|
||||
// POST /api/admin/users
|
||||
// PATCH /api/admin/users/{id}
|
||||
// POST /api/admin/users/{id}/password
|
||||
// DELETE /api/admin/users/{id}
|
||||
// GET /api/parts POST /api/parts
|
||||
// GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/pb"
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
"drivervault/apiserver/internal/plugins"
|
||||
_ "drivervault/apiserver/internal/plugins/builtin" // register built-in plugins
|
||||
)
|
||||
|
||||
// PocketBase collection names.
|
||||
@@ -60,53 +73,90 @@ const (
|
||||
colCars = "cars"
|
||||
colServices = "service_records"
|
||||
colParts = "parts"
|
||||
colSessions = "sessions"
|
||||
colShares = "car_shares"
|
||||
colOrgs = "organizations"
|
||||
)
|
||||
|
||||
// Server wires together the HTTP handlers and their dependencies.
|
||||
type Server struct {
|
||||
pb *pb.Client
|
||||
corsOrigins map[string]bool
|
||||
authSecret string
|
||||
usersCollection string
|
||||
mu sync.RWMutex // guards the mutable PocketBase connection in cfg
|
||||
cfg config.Config
|
||||
pb *pb.Client
|
||||
plugins *plugins.Manager
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
CORSOrigins []string
|
||||
AuthSecret string
|
||||
UsersCollection string
|
||||
}
|
||||
|
||||
func NewServer(client *pb.Client, opts Options) *Server {
|
||||
set := make(map[string]bool, len(opts.CORSOrigins))
|
||||
for _, o := range opts.CORSOrigins {
|
||||
set[o] = true
|
||||
}
|
||||
// New constructs a Server around an already-built PocketBase client.
|
||||
func New(cfg config.Config, client *pb.Client) *Server {
|
||||
return &Server{
|
||||
pb: client,
|
||||
corsOrigins: set,
|
||||
authSecret: opts.AuthSecret,
|
||||
usersCollection: opts.UsersCollection,
|
||||
cfg: cfg,
|
||||
pb: client,
|
||||
plugins: plugins.NewManager(cfg.PluginsFile),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler builds the routed, CORS-wrapped HTTP handler (Go 1.22 ServeMux).
|
||||
// StartPlugins loads persisted plugin state and initialises enabled plugins.
|
||||
func (s *Server) StartPlugins() error { return s.plugins.Load() }
|
||||
|
||||
// Stop releases server-held resources (currently: plugin instances).
|
||||
func (s *Server) Stop(ctx context.Context) { s.plugins.Shutdown(ctx) }
|
||||
|
||||
// usersCollection returns the PocketBase auth collection holding app users.
|
||||
func (s *Server) usersCollection() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg.UsersCollection
|
||||
}
|
||||
|
||||
// webAppURL returns the Web App address probed by /api/status.
|
||||
func (s *Server) webAppURL() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg.WebAppURL
|
||||
}
|
||||
|
||||
// pbSettings snapshots the PocketBase connection for the settings endpoints.
|
||||
func (s *Server) pbSettings() (url, adminEmail, adminPassword string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg.PocketBaseURL, s.cfg.PocketBaseAdminEmail, s.cfg.PocketBaseAdminPassword
|
||||
}
|
||||
|
||||
// setPBConfig retargets the PocketBase connection at runtime: it updates the
|
||||
// cached config and repoints the client (which drops its cached superuser token,
|
||||
// so the next call re-authenticates against the new target).
|
||||
func (s *Server) setPBConfig(url, adminEmail, adminPassword string) {
|
||||
s.mu.Lock()
|
||||
s.cfg.PocketBaseURL = url
|
||||
s.cfg.PocketBaseAdminEmail = adminEmail
|
||||
s.cfg.PocketBaseAdminPassword = adminPassword
|
||||
s.mu.Unlock()
|
||||
|
||||
s.pb.Reconfigure(url, adminEmail, adminPassword)
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler with all routes registered.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// DriverVault web panel (public) — embedded Vue + Tailwind app served at the
|
||||
// root. Only explicit panel paths are routed to it, so unknown /api/* paths
|
||||
// still 404 as JSON rather than serving the SPA shell.
|
||||
// Web panel (public) — embedded Vue + Tailwind app. Only the explicit panel
|
||||
// paths are routed to it so unknown /api/* paths still 404 as JSON.
|
||||
panel := panelHandler()
|
||||
mux.Handle("GET /{$}", panel)
|
||||
mux.Handle("GET /assets/", panel)
|
||||
mux.Handle("GET /favicon.svg", panel)
|
||||
|
||||
// Health (public).
|
||||
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||
|
||||
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
|
||||
mux.HandleFunc("GET /api/auth/me", s.handleMe)
|
||||
// Auth — proxied to the PocketBase kept behind this server.
|
||||
mux.HandleFunc("POST /api/auth/login", s.handleAuthLogin)
|
||||
mux.HandleFunc("GET /api/auth/validate", s.handleAuthValidate)
|
||||
mux.HandleFunc("GET /api/auth/me", s.handleAuthMe)
|
||||
mux.HandleFunc("GET /api/identity", s.handleIdentity)
|
||||
|
||||
// Current user (profile / appearance / avatar / data / account lifecycle).
|
||||
mux.HandleFunc("GET /api/me", s.handleGetMe)
|
||||
mux.HandleFunc("PATCH /api/me", s.handleUpdateMe)
|
||||
mux.HandleFunc("POST /api/me/password", s.handleChangePassword)
|
||||
@@ -120,16 +170,36 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/me/delete/cancel", s.handleCancelDeletion)
|
||||
mux.HandleFunc("DELETE /api/me", s.handleFinalizeDeletion)
|
||||
|
||||
mux.HandleFunc("GET /api/sessions", s.handleListSessions)
|
||||
mux.HandleFunc("DELETE /api/sessions/{id}", s.handleRevokeSession)
|
||||
mux.HandleFunc("DELETE /api/sessions", s.handleRevokeOtherSessions)
|
||||
// User management — gated on the caller being a manager (admin or
|
||||
// superadmin). Admins are scoped to their own organization inside each
|
||||
// handler.
|
||||
mux.HandleFunc("GET /api/users", s.requireManager(s.handleListUsers))
|
||||
mux.HandleFunc("POST /api/users", s.requireManager(s.handleCreateUser))
|
||||
mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser))
|
||||
mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser))
|
||||
|
||||
mux.HandleFunc("GET /api/admin/users", s.handleListUsers)
|
||||
mux.HandleFunc("POST /api/admin/users", s.handleCreateUser)
|
||||
mux.HandleFunc("PATCH /api/admin/users/{id}", s.handleUpdateUser)
|
||||
mux.HandleFunc("POST /api/admin/users/{id}/password", s.handleSetUserPassword)
|
||||
mux.HandleFunc("DELETE /api/admin/users/{id}", s.handleDeleteUser)
|
||||
// Organizations — listing is manager-scoped; create/edit/delete are
|
||||
// superadmin-only (a superadmin spans all organizations).
|
||||
mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs))
|
||||
mux.HandleFunc("POST /api/orgs", s.requireSuperadmin(s.handleCreateOrg))
|
||||
mux.HandleFunc("PATCH /api/orgs/{id}", s.requireSuperadmin(s.handleUpdateOrg))
|
||||
mux.HandleFunc("DELETE /api/orgs/{id}", s.requireSuperadmin(s.handleDeleteOrg))
|
||||
|
||||
// PocketBase connection settings — superadmin only. These do NOT require the
|
||||
// service account to already be configured (they exist to configure it).
|
||||
mux.HandleFunc("GET /api/admin/pb-config", s.requireSuperadminAuth(s.handleGetPBConfig))
|
||||
mux.HandleFunc("POST /api/admin/pb-config/test", s.requireSuperadminAuth(s.handleTestPBConfig))
|
||||
mux.HandleFunc("PUT /api/admin/pb-config", s.requireSuperadminAuth(s.handleUpdatePBConfig))
|
||||
|
||||
// Plugins — external-service integrations, managed by a superadmin.
|
||||
mux.HandleFunc("GET /api/admin/plugins", s.requireSuperadminAuth(s.handleListPlugins))
|
||||
mux.HandleFunc("POST /api/admin/plugins", s.requireSuperadminAuth(s.handleRegisterPlugin))
|
||||
mux.HandleFunc("GET /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleGetPlugin))
|
||||
mux.HandleFunc("PUT /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleUpdatePlugin))
|
||||
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
|
||||
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
|
||||
|
||||
// Cars + sharing.
|
||||
mux.HandleFunc("GET /api/cars", s.listCars)
|
||||
mux.HandleFunc("POST /api/cars", s.createCar)
|
||||
mux.HandleFunc("GET /api/cars/{id}", s.getCar)
|
||||
@@ -137,43 +207,74 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar)
|
||||
mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords)
|
||||
mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts)
|
||||
|
||||
mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares)
|
||||
mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare)
|
||||
mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare)
|
||||
|
||||
// Service records.
|
||||
mux.HandleFunc("GET /api/service-records", s.listServiceRecords)
|
||||
mux.HandleFunc("POST /api/service-records", s.createServiceRecord)
|
||||
mux.HandleFunc("GET /api/service-records/{id}", s.getServiceRecord)
|
||||
mux.HandleFunc("PATCH /api/service-records/{id}", s.updateServiceRecord)
|
||||
mux.HandleFunc("DELETE /api/service-records/{id}", s.deleteServiceRecord)
|
||||
|
||||
// Parts.
|
||||
mux.HandleFunc("GET /api/parts", s.listParts)
|
||||
mux.HandleFunc("POST /api/parts", s.createPart)
|
||||
mux.HandleFunc("GET /api/parts/{id}", s.getPart)
|
||||
mux.HandleFunc("PATCH /api/parts/{id}", s.updatePart)
|
||||
mux.HandleFunc("DELETE /api/parts/{id}", s.deletePart)
|
||||
|
||||
return s.withCORS(s.withLogging(s.withAuth(mux)))
|
||||
return s.withMiddleware(mux)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"time": time.Now().UTC().Format(time.RFC3339),
|
||||
// withMiddleware applies panic recovery, CORS, request logging, and
|
||||
// authentication globally.
|
||||
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
||||
return s.recoverer(s.cors(s.logger(s.withAuth(next))))
|
||||
}
|
||||
|
||||
func (s *Server) logger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
// --- middleware ---
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("panic: %v", rec)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
allowed := map[string]bool{}
|
||||
wildcard := false
|
||||
for _, o := range s.cfg.AllowOrigins {
|
||||
if o == "*" {
|
||||
wildcard = true
|
||||
}
|
||||
allowed[o] = true
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && (s.corsOrigins[origin] || s.corsOrigins["*"]) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
if origin != "" && (wildcard || allowed[origin]) {
|
||||
if wildcard {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -183,43 +284,22 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) withLogging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
// statusWriter captures the response status code for logging.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wrote bool
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if v != nil {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if !w.wrote {
|
||||
w.status = code
|
||||
w.wrote = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// writePBError maps a PocketBase error to an appropriate HTTP status.
|
||||
func writePBError(w http.ResponseWriter, err error) {
|
||||
if apiErr, ok := err.(*pb.APIError); ok {
|
||||
status := apiErr.Status
|
||||
if status < 400 {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
writeError(w, status, apiErr.Body)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, dest any) error {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dest)
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
w.wrote = true
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"carcontrol/api/internal/models"
|
||||
"drivervault/apiserver/internal/models"
|
||||
)
|
||||
|
||||
func (s *Server) listServiceRecords(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"carcontrol/api/internal/auth"
|
||||
"carcontrol/api/internal/models"
|
||||
)
|
||||
|
||||
// handleListSessions lists the current user's active (non-revoked) logins,
|
||||
// flagging which one is the request being made right now.
|
||||
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.pb.List(r.Context(), colSessions, url.Values{
|
||||
"filter": {fmt.Sprintf("user='%s' && revoked=false", claims.Sub)},
|
||||
"sort": {"-created"},
|
||||
"perPage": {"50"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []sessionRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]models.Session, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, models.Session{
|
||||
ID: rec.ID,
|
||||
DeviceLabel: rec.DeviceLabel,
|
||||
IP: rec.IP,
|
||||
Current: rec.Jti == claims.Jti,
|
||||
Created: parsePBDate(rec.Created),
|
||||
ExpiresAt: parsePBDate(rec.ExpiresAt),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleRevokeSession logs out one specific device (including possibly the
|
||||
// current one, same as an ordinary logout).
|
||||
func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
var rec sessionRecord
|
||||
if err := s.pb.GetOne(r.Context(), colSessions, id, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if rec.User != claims.Sub {
|
||||
writeError(w, http.StatusNotFound, "session not found")
|
||||
return
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), colSessions, id, map[string]any{"revoked": true}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRevokeOtherSessions logs out every device except the one making this
|
||||
// request ("log out everywhere else").
|
||||
func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := s.pb.List(r.Context(), colSessions, url.Values{
|
||||
"filter": {fmt.Sprintf("user='%s' && revoked=false && jti!='%s'", claims.Sub, claims.Jti)},
|
||||
"perPage": {"200"},
|
||||
})
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var recs []sessionRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, rec := range recs {
|
||||
if err := s.pb.Update(r.Context(), colSessions, rec.ID, map[string]any{"revoked": true}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"revoked": len(recs)})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
// pbProbe is the outcome of testing a PocketBase connection: whether the base
|
||||
// URL answers its health check and whether the service-account credentials
|
||||
// authenticate as a superuser.
|
||||
type pbProbe struct {
|
||||
Reachable bool `json:"reachable"`
|
||||
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Superuser bool `json:"superuser"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// pbConfigView is the PocketBase-connection shape returned to the panel. The
|
||||
// password itself is never sent back — only whether one is set.
|
||||
type pbConfigView struct {
|
||||
URL string `json:"url"`
|
||||
AdminEmail string `json:"adminEmail"`
|
||||
AdminConfigured bool `json:"adminConfigured"`
|
||||
Probe pbProbe `json:"probe"`
|
||||
}
|
||||
|
||||
// probePB checks a PocketBase base URL's health and, when credentials are given,
|
||||
// whether they authenticate as a superuser. It uses the short-timeout
|
||||
// healthClient so a hung PocketBase cannot stall the request.
|
||||
func probePB(ctx context.Context, url, email, password string) pbProbe {
|
||||
h := probe(ctx, url+"/api/health")
|
||||
p := pbProbe{Reachable: h.Status == "ok", HTTPStatus: h.HTTPStatus, LatencyMs: h.LatencyMs}
|
||||
if h.Error != "" {
|
||||
p.Detail = h.Error
|
||||
}
|
||||
if email != "" && password != "" {
|
||||
st, err := pb.SuperuserAuth(ctx, healthClient, url, email, password)
|
||||
if err == nil {
|
||||
p.Superuser = true
|
||||
} else if p.Reachable {
|
||||
p.Detail = "superuser auth failed"
|
||||
if st > 0 {
|
||||
p.Detail += " (HTTP " + strconv.Itoa(st) + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// normalizePBURL trims, defaults the scheme to http, and drops a trailing slash.
|
||||
func normalizePBURL(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
||||
u = "http://" + u
|
||||
}
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
|
||||
// viewFor builds the panel's connection view, including a live probe.
|
||||
func viewFor(ctx context.Context, url, email, password string) pbConfigView {
|
||||
return pbConfigView{
|
||||
URL: url,
|
||||
AdminEmail: email,
|
||||
AdminConfigured: email != "" && password != "",
|
||||
Probe: probePB(ctx, url, email, password),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/admin/pb-config — current PocketBase connection + a live probe.
|
||||
func (s *Server) handleGetPBConfig(w http.ResponseWriter, r *http.Request) {
|
||||
url, email, password := s.pbSettings()
|
||||
writeJSON(w, http.StatusOK, viewFor(r.Context(), url, email, password))
|
||||
}
|
||||
|
||||
// pbConfigBody is the editable connection payload. A blank adminPassword means
|
||||
// "keep the current one"; a blank adminEmail/url means "keep current".
|
||||
type pbConfigBody struct {
|
||||
URL string `json:"url"`
|
||||
AdminEmail string `json:"adminEmail"`
|
||||
AdminPassword string `json:"adminPassword"`
|
||||
}
|
||||
|
||||
// resolve merges a request body onto the current settings, applying the
|
||||
// keep-current semantics for blank fields.
|
||||
func (s *Server) resolve(b pbConfigBody) (url, email, password string) {
|
||||
curURL, curEmail, curPassword := s.pbSettings()
|
||||
url = normalizePBURL(b.URL)
|
||||
if url == "" {
|
||||
url = curURL
|
||||
}
|
||||
email = strings.TrimSpace(b.AdminEmail)
|
||||
if email == "" {
|
||||
email = curEmail
|
||||
}
|
||||
password = b.AdminPassword
|
||||
if password == "" {
|
||||
password = curPassword
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// POST /api/admin/pb-config/test — probe a candidate connection WITHOUT applying
|
||||
// it, so a superadmin can verify before saving.
|
||||
func (s *Server) handleTestPBConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var b pbConfigBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
url, email, password := s.resolve(b)
|
||||
writeJSON(w, http.StatusOK, probePB(r.Context(), url, email, password))
|
||||
}
|
||||
|
||||
// PUT /api/admin/pb-config — apply a new PocketBase connection at runtime and
|
||||
// persist it to .env. Returns the new config plus a fresh probe.
|
||||
func (s *Server) handleUpdatePBConfig(w http.ResponseWriter, r *http.Request) {
|
||||
var b pbConfigBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if normalizePBURL(b.URL) == "" {
|
||||
writeError(w, http.StatusBadRequest, "a PocketBase URL is required")
|
||||
return
|
||||
}
|
||||
url, email, password := s.resolve(b)
|
||||
|
||||
// Apply at runtime, then persist so the change survives a restart.
|
||||
s.setPBConfig(url, email, password)
|
||||
if err := config.UpdateEnvFile(config.EnvFile, map[string]string{
|
||||
"POCKETBASE_URL": url,
|
||||
"POCKETBASE_ADMIN_EMAIL": email,
|
||||
"POCKETBASE_ADMIN_PASSWORD": password,
|
||||
}); err != nil {
|
||||
// The runtime change already took effect; report that persistence failed.
|
||||
log.Printf("pb-config: persist to %s failed: %v", config.EnvFile, err)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"config": viewFor(r.Context(), url, email, password),
|
||||
"warning": "applied for this session, but could not be saved to .env: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("pb-config: PocketBase connection updated to %s (by superadmin)", url)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"config": viewFor(r.Context(), url, email, password),
|
||||
})
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func (s *Server) findShare(r *http.Request, carID, userID string) (*shareRecord,
|
||||
// findUserByEmail looks up a user in the auth collection by email, returning nil
|
||||
// if none matches.
|
||||
func (s *Server) findUserByEmail(r *http.Request, email string) (*userRecord, error) {
|
||||
res, err := s.pb.List(r.Context(), s.usersCollection, url.Values{
|
||||
res, err := s.pb.List(r.Context(), s.usersCollection(), url.Values{
|
||||
"filter": {fmt.Sprintf("email='%s'", strings.ReplaceAll(email, "'", ""))},
|
||||
"perPage": {"1"},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// svcHealth is the health of one upstream service, as shown on the panel.
|
||||
type svcHealth struct {
|
||||
Status string `json:"status"` // "ok" | "down"
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// healthClient is a short-timeout client for probing upstreams so a hung
|
||||
// dependency can't stall the status endpoint.
|
||||
var healthClient = &http.Client{Timeout: 4 * time.Second}
|
||||
|
||||
// probe does a GET against url and classifies the result.
|
||||
func probe(ctx context.Context, url string) svcHealth {
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return svcHealth{Status: "down", URL: url, Error: err.Error()}
|
||||
}
|
||||
resp, err := healthClient.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return svcHealth{Status: "down", URL: url, LatencyMs: lat, Error: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
status := "ok"
|
||||
if resp.StatusCode >= 400 {
|
||||
status = "down"
|
||||
}
|
||||
return svcHealth{Status: status, LatencyMs: lat, HTTPStatus: resp.StatusCode, URL: url}
|
||||
}
|
||||
|
||||
// GET /api/status — aggregate health of the API Server and its neighbours
|
||||
// (PocketBase and the Web App), probed server-side. The panel polls this so the
|
||||
// browser never has to reach PocketBase or the Web App directly.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
var pbHealth, web svcHealth
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); pbHealth = probe(r.Context(), s.pb.BaseURL()+"/api/health") }()
|
||||
go func() { defer wg.Done(); web = probe(r.Context(), s.webAppURL()+"/healthz") }()
|
||||
wg.Wait()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"apiServer": map[string]any{"status": "ok"},
|
||||
"pocketBase": pbHealth,
|
||||
"webApp": web,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// userView is the trimmed user shape returned to managers.
|
||||
type userView struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Verified bool `json:"verified"`
|
||||
Created string `json:"created"`
|
||||
Organization string `json:"organization"` // org record id ("" = none)
|
||||
OrganizationName string `json:"organizationName"` // resolved name ("" = none)
|
||||
}
|
||||
|
||||
// userFields is the field set fetched for a userView.
|
||||
const userFields = "id,email,name,role,verified,created,organization"
|
||||
|
||||
// getUserRecord fetches a single user via the service account. Returns nil (not
|
||||
// an error) when the user does not exist.
|
||||
func (s *Server) getUserRecord(ctx context.Context, id string) (*userView, error) {
|
||||
path := "/api/collections/" + s.usersCollection() + "/records/" + url.PathEscape(id) + "?fields=" + userFields
|
||||
data, status, err := s.pb.Raw(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, nil
|
||||
}
|
||||
var v userView
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v.Role == "" {
|
||||
v.Role = roleUser
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// GET /api/users — list users (manager only). Superadmins see everyone; admins
|
||||
// see only their own organization's members.
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
path := "/api/collections/" + s.usersCollection() + "/records?perPage=500&sort=email&fields=" + userFields
|
||||
if !who.isSuperadmin() {
|
||||
// Admin: scope to their own organization. An org-less admin manages nobody.
|
||||
if who.OrgID == "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": []userView{}})
|
||||
return
|
||||
}
|
||||
path += "&filter=" + url.QueryEscape("organization = \""+who.OrgID+"\"")
|
||||
}
|
||||
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relay(w, status, data)
|
||||
return
|
||||
}
|
||||
var list struct {
|
||||
Items []userView `json:"items"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &list)
|
||||
names := s.orgNameMap(r.Context())
|
||||
for i := range list.Items {
|
||||
if list.Items[i].Role == "" {
|
||||
list.Items[i].Role = roleUser
|
||||
}
|
||||
list.Items[i].OrganizationName = names[list.Items[i].Organization]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": list.Items})
|
||||
}
|
||||
|
||||
// POST /api/users — create a user (manager only). Body: {email, password, name?,
|
||||
// role?, organization?}. Admins may only create within their own org and may not
|
||||
// mint superadmins; superadmins may target any org (or none) and any role.
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Organization string `json:"organization"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Email = strings.TrimSpace(strings.ToLower(body.Email))
|
||||
if body.Email == "" || !strings.Contains(body.Email, "@") {
|
||||
writeError(w, http.StatusBadRequest, "a valid email is required")
|
||||
return
|
||||
}
|
||||
if len(body.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
role, ok := normalizeRole(body.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
|
||||
return
|
||||
}
|
||||
org := strings.TrimSpace(body.Organization)
|
||||
|
||||
if !who.isSuperadmin() {
|
||||
// Admin: no superadmins, and members are forced into the admin's own org.
|
||||
if role == roleSuperadmin {
|
||||
writeError(w, http.StatusForbidden, "only a superadmin can create superadmins")
|
||||
return
|
||||
}
|
||||
if who.OrgID == "" {
|
||||
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
||||
return
|
||||
}
|
||||
org = who.OrgID
|
||||
}
|
||||
|
||||
create := map[string]any{
|
||||
"email": body.Email,
|
||||
"password": body.Password,
|
||||
"passwordConfirm": body.Password,
|
||||
"name": strings.TrimSpace(body.Name),
|
||||
"role": role,
|
||||
"verified": true,
|
||||
"emailVisibility": false,
|
||||
}
|
||||
// Only send organization when set; a superadmin may deliberately omit it to
|
||||
// create an org-less account.
|
||||
if org != "" {
|
||||
create["organization"] = org
|
||||
}
|
||||
|
||||
data, status, err := s.pb.Raw(r.Context(), http.MethodPost, "/api/collections/"+s.usersCollection()+"/records", create)
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
// Relay PocketBase's validation error (e.g. duplicate email, bad org id).
|
||||
relay(w, status, data)
|
||||
return
|
||||
}
|
||||
var rec userView
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
if rec.Role == "" {
|
||||
rec.Role = role
|
||||
}
|
||||
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"user": rec})
|
||||
}
|
||||
|
||||
// PATCH /api/users/{id} — edit a user (manager only). Any subset of
|
||||
// {email, name, role, password, verified, organization} may be supplied. Admins
|
||||
// are scoped to their own org and cannot touch superadmins or grant the
|
||||
// superadmin role; nobody can change their own role (avoids self-lockout).
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing user id")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Verified *bool `json:"verified"`
|
||||
Organization *string `json:"organization"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the target so we can enforce org/role scoping.
|
||||
target, err := s.getUserRecord(r.Context(), id)
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if target == nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
if !who.isSuperadmin() {
|
||||
// Admin scoping: target must be inside the admin's org and not a superadmin.
|
||||
if who.OrgID == "" || target.Organization != who.OrgID {
|
||||
writeError(w, http.StatusForbidden, "user is outside your organization")
|
||||
return
|
||||
}
|
||||
if target.Role == roleSuperadmin {
|
||||
writeError(w, http.StatusForbidden, "you cannot edit a superadmin")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
patch := map[string]any{}
|
||||
|
||||
if email := strings.TrimSpace(strings.ToLower(body.Email)); email != "" {
|
||||
if !strings.Contains(email, "@") {
|
||||
writeError(w, http.StatusBadRequest, "a valid email is required")
|
||||
return
|
||||
}
|
||||
patch["email"] = email
|
||||
}
|
||||
if body.Name != nil {
|
||||
patch["name"] = strings.TrimSpace(*body.Name)
|
||||
}
|
||||
if body.Role != "" {
|
||||
role, ok := normalizeRole(body.Role)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
|
||||
return
|
||||
}
|
||||
if !who.isSuperadmin() && role == roleSuperadmin {
|
||||
writeError(w, http.StatusForbidden, "only a superadmin can grant the superadmin role")
|
||||
return
|
||||
}
|
||||
if who.ID == id && role != who.Role {
|
||||
writeError(w, http.StatusBadRequest, "you cannot change your own role")
|
||||
return
|
||||
}
|
||||
patch["role"] = role
|
||||
}
|
||||
if body.Password != "" {
|
||||
if len(body.Password) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
patch["password"] = body.Password
|
||||
patch["passwordConfirm"] = body.Password
|
||||
}
|
||||
if body.Verified != nil {
|
||||
patch["verified"] = *body.Verified
|
||||
}
|
||||
// Organization moves are superadmin-only; admins cannot reassign membership.
|
||||
if body.Organization != nil {
|
||||
if !who.isSuperadmin() {
|
||||
if *body.Organization != who.OrgID {
|
||||
writeError(w, http.StatusForbidden, "you cannot move users to another organization")
|
||||
return
|
||||
}
|
||||
// no-op for admins staying in their own org
|
||||
} else {
|
||||
patch["organization"] = *body.Organization // "" clears membership
|
||||
}
|
||||
}
|
||||
if len(patch) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "no changes provided")
|
||||
return
|
||||
}
|
||||
|
||||
data, status, err := s.pb.Raw(r.Context(), http.MethodPatch,
|
||||
"/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(id), patch)
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relay(w, status, data)
|
||||
return
|
||||
}
|
||||
var rec userView
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
if rec.Role == "" {
|
||||
rec.Role = roleUser
|
||||
}
|
||||
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"user": rec})
|
||||
}
|
||||
|
||||
// DELETE /api/users/{id} — delete a user (manager only). Admins may delete only
|
||||
// non-superadmin members of their own org; nobody can delete their own account.
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing user id")
|
||||
return
|
||||
}
|
||||
if who.ID == id {
|
||||
writeError(w, http.StatusBadRequest, "you cannot delete your own account")
|
||||
return
|
||||
}
|
||||
|
||||
if !who.isSuperadmin() {
|
||||
target, err := s.getUserRecord(r.Context(), id)
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if target == nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if who.OrgID == "" || target.Organization != who.OrgID {
|
||||
writeError(w, http.StatusForbidden, "user is outside your organization")
|
||||
return
|
||||
}
|
||||
if target.Role == roleSuperadmin {
|
||||
writeError(w, http.StatusForbidden, "you cannot delete a superadmin")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, status, err := s.pb.Raw(r.Context(), http.MethodDelete,
|
||||
"/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(id), nil)
|
||||
if err != nil {
|
||||
writeUpstreamDown(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK && status != http.StatusNoContent {
|
||||
relay(w, status, data)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// normalizeRole validates a client-supplied role. Returns the canonical value
|
||||
// and whether it was recognised.
|
||||
func normalizeRole(role string) (string, bool) {
|
||||
switch strings.TrimSpace(strings.ToLower(role)) {
|
||||
case "", roleUser:
|
||||
return roleUser, true
|
||||
case roleAdmin:
|
||||
return roleAdmin, true
|
||||
case roleSuperadmin:
|
||||
return roleSuperadmin, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// Package auth implements minimal HS256 JSON Web Tokens using only the standard
|
||||
// library. The API Server issues a token after verifying a user's credentials
|
||||
// against PocketBase, and verifies that token on every protected request.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrExpired = errors.New("token expired")
|
||||
)
|
||||
|
||||
// Claims is the JWT payload carried for an authenticated user.
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"` // user id
|
||||
Email string `json:"email"` // user email
|
||||
Name string `json:"name"` // display name (optional)
|
||||
Role string `json:"role,omitempty"` // access role: "user" | "admin"
|
||||
Jti string `json:"jti"` // id of the backing "sessions" record, for revocation
|
||||
Iat int64 `json:"iat"` // issued-at (unix seconds)
|
||||
Exp int64 `json:"exp"` // expiry (unix seconds)
|
||||
}
|
||||
|
||||
// NewJTI generates a random session identifier, hex-encoded so it's always a
|
||||
// safe, quote-free literal to embed directly in PocketBase filter strings.
|
||||
func NewJTI() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
const headerB64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" // {"alg":"HS256","typ":"JWT"}
|
||||
|
||||
// Sign creates a signed token for the given claims and lifetime.
|
||||
func Sign(secret string, c Claims, ttl time.Duration) (string, error) {
|
||||
now := time.Now()
|
||||
c.Iat = now.Unix()
|
||||
c.Exp = now.Add(ttl).Unix()
|
||||
|
||||
payload, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signingInput := headerB64 + "." + b64(payload)
|
||||
sig := sign(signingInput, secret)
|
||||
return signingInput + "." + sig, nil
|
||||
}
|
||||
|
||||
// Verify checks the signature and expiry, returning the embedded claims.
|
||||
func Verify(secret, token string) (*Claims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
expected := sign(signingInput, secret)
|
||||
// Constant-time comparison to avoid timing leaks.
|
||||
if !hmac.Equal([]byte(expected), []byte(parts[2])) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
var c Claims
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if time.Now().Unix() >= c.Exp {
|
||||
return nil, ErrExpired
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func sign(input, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(input))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func b64(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -1,65 +1,147 @@
|
||||
// Package config loads server configuration from environment variables,
|
||||
// optionally seeded from a .env file in the working directory.
|
||||
// optionally seeded from a .env file in the working directory. The PocketBase
|
||||
// connection is also editable at runtime from the panel, which persists the
|
||||
// change back into the same .env via UpdateEnvFile.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration for the API Server.
|
||||
type Config struct {
|
||||
Port string
|
||||
PBURL string
|
||||
PBAdminEmail string
|
||||
PBAdminPasswd string
|
||||
CORSOrigins []string
|
||||
AuthSecret string
|
||||
Addr string
|
||||
PocketBaseURL string
|
||||
WebAppURL string
|
||||
AllowOrigins []string
|
||||
|
||||
// UsersCollection is the PocketBase auth collection holding app users.
|
||||
UsersCollection string
|
||||
|
||||
// PluginsFile is the local JSON store for plugin enable-state + config.
|
||||
PluginsFile string
|
||||
|
||||
// Superuser service account. Every privileged flow (user/organization
|
||||
// management, all car-domain database access) runs through it. Optional at
|
||||
// startup: when unset those endpoints return 503 and a superadmin can still
|
||||
// log in to the panel to configure it.
|
||||
PocketBaseAdminEmail string
|
||||
PocketBaseAdminPassword string
|
||||
}
|
||||
|
||||
// devAuthSecret is used only when AUTH_SECRET is unset, so the server still
|
||||
// runs out-of-the-box in development. Set AUTH_SECRET in production.
|
||||
const devAuthSecret = "dev-insecure-secret-change-me"
|
||||
// EnvFile is the .env path (relative to the working directory) that Load reads
|
||||
// and that runtime settings changes persist back into.
|
||||
const EnvFile = ".env"
|
||||
|
||||
// Load reads .env (if present) into the process environment, then builds a
|
||||
// Config from environment variables. Required values that are missing produce
|
||||
// an error so the server fails fast instead of misbehaving later.
|
||||
func Load() (*Config, error) {
|
||||
loadDotEnv(".env")
|
||||
// AdminConfigured reports whether a service account has been supplied.
|
||||
func (c Config) AdminConfigured() bool {
|
||||
return c.PocketBaseAdminEmail != "" && c.PocketBaseAdminPassword != ""
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Port: getenv("PORT", "8080"),
|
||||
PBURL: strings.TrimRight(getenv("PB_URL", "http://10.2.1.10:8027"), "/"),
|
||||
PBAdminEmail: os.Getenv("PB_ADMIN_EMAIL"),
|
||||
PBAdminPasswd: os.Getenv("PB_ADMIN_PASSWORD"),
|
||||
CORSOrigins: splitCSV(getenv("CORS_ORIGINS", "http://localhost:5173")),
|
||||
AuthSecret: getenv("AUTH_SECRET", devAuthSecret),
|
||||
UsersCollection: getenv("AUTH_USERS_COLLECTION", "users"),
|
||||
// Load reads configuration from environment variables, applying sensible
|
||||
// defaults. A .env file, if present in the working directory, is loaded first.
|
||||
func Load() Config {
|
||||
loadDotEnv(EnvFile)
|
||||
|
||||
return Config{
|
||||
Addr: normalizeAddr(firstEnv("API_ADDR", "PORT"), ":8080"),
|
||||
PocketBaseURL: strings.TrimRight(firstEnvOr("http://10.2.1.10:8027", "POCKETBASE_URL", "PB_URL"), "/"),
|
||||
WebAppURL: strings.TrimRight(getenv("WEBAPP_URL", "http://localhost:5173"), "/"),
|
||||
AllowOrigins: splitCSV(firstEnvOr("*", "CORS_ALLOW_ORIGINS", "CORS_ORIGINS")),
|
||||
UsersCollection: getenv("AUTH_USERS_COLLECTION", "users"),
|
||||
PluginsFile: getenv("PLUGINS_FILE", "plugins.json"),
|
||||
PocketBaseAdminEmail: firstEnv("POCKETBASE_ADMIN_EMAIL", "PB_ADMIN_EMAIL"),
|
||||
PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"),
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeAddr accepts either a full listen address (":8080") or a bare port
|
||||
// ("8080", which is what the legacy PORT variable held) and returns a listen
|
||||
// address.
|
||||
func normalizeAddr(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
if strings.Contains(v, ":") {
|
||||
return v
|
||||
}
|
||||
return ":" + v
|
||||
}
|
||||
|
||||
// UpdateEnvFile persists the given KEY=VALUE pairs into the .env file at path,
|
||||
// replacing existing keys in place and appending new ones, while preserving all
|
||||
// other lines (comments, ordering, unrelated keys). The file is created if it
|
||||
// does not exist. Written with 0600 perms since it holds secrets.
|
||||
func UpdateEnvFile(path string, updates map[string]string) error {
|
||||
existing, _ := os.ReadFile(path) // missing file → start empty
|
||||
|
||||
remaining := make(map[string]string, len(updates))
|
||||
for k, v := range updates {
|
||||
remaining[k] = v
|
||||
}
|
||||
|
||||
if cfg.PBAdminEmail == "" || cfg.PBAdminPasswd == "" {
|
||||
return nil, fmt.Errorf("PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required")
|
||||
var out []string
|
||||
for _, line := range strings.Split(string(existing), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
out = append(out, line)
|
||||
continue
|
||||
}
|
||||
key, _, ok := strings.Cut(trimmed, "=")
|
||||
key = strings.TrimSpace(key)
|
||||
if ok {
|
||||
if v, found := remaining[key]; found {
|
||||
out = append(out, key+"="+v)
|
||||
delete(remaining, key)
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return cfg, nil
|
||||
// Append any keys that weren't already present.
|
||||
for k, v := range remaining {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
|
||||
content := strings.Join(out, "\n")
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
|
||||
// UsingDevAuthSecret reports whether the insecure development secret is in use.
|
||||
func (c *Config) UsingDevAuthSecret() bool {
|
||||
return c.AuthSecret == devAuthSecret
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
return def
|
||||
}
|
||||
|
||||
// firstEnv returns the first of keys that is set to a non-empty value. It lets
|
||||
// the modern POCKETBASE_* names take precedence while the legacy PB_* names from
|
||||
// older deployments keep working.
|
||||
func firstEnv(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstEnvOr is firstEnv with a fallback when none of the keys are set.
|
||||
func firstEnvOr(def string, keys ...string) string {
|
||||
if v := firstEnv(keys...); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
@@ -67,18 +149,16 @@ func splitCSV(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// loadDotEnv parses a simple KEY=VALUE file and sets any variables that are not
|
||||
// already present in the environment. Lines starting with # are comments.
|
||||
// loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they
|
||||
// are not already set. It is intentionally minimal (no quoting rules beyond
|
||||
// trimming surrounding quotes).
|
||||
func loadDotEnv(path string) {
|
||||
f, err := os.Open(path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return // .env is optional
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
@@ -89,7 +169,7 @@ func loadDotEnv(path string) {
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
os.Setenv(key, val)
|
||||
_ = os.Setenv(key, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,18 @@ import (
|
||||
)
|
||||
|
||||
// Client is a concurrency-safe PocketBase REST client with auto re-auth.
|
||||
//
|
||||
// The target address and service-account credentials are guarded by mu because
|
||||
// a superadmin can retarget them at runtime (Settings → PocketBase in the panel)
|
||||
// while requests are in flight.
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
baseURL string
|
||||
email string
|
||||
password string
|
||||
http *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
token string
|
||||
token string
|
||||
}
|
||||
|
||||
func New(baseURL, email, password string) *Client {
|
||||
@@ -36,6 +40,38 @@ func New(baseURL, email, password string) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// creds snapshots the connection under lock so a concurrent Reconfigure can't
|
||||
// tear it mid-request.
|
||||
func (c *Client) creds() (baseURL, email, password string) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.baseURL, c.email, c.password
|
||||
}
|
||||
|
||||
// BaseURL returns the PocketBase address currently in use.
|
||||
func (c *Client) BaseURL() string {
|
||||
baseURL, _, _ := c.creds()
|
||||
return baseURL
|
||||
}
|
||||
|
||||
// Configured reports whether a service account has been supplied. Endpoints that
|
||||
// need superuser access check this and return 503 when it is false.
|
||||
func (c *Client) Configured() bool {
|
||||
_, email, password := c.creds()
|
||||
return email != "" && password != ""
|
||||
}
|
||||
|
||||
// Reconfigure retargets the client at a new PocketBase and/or new credentials,
|
||||
// invalidating any cached superuser token so the next call re-authenticates.
|
||||
func (c *Client) Reconfigure(baseURL, email, password string) {
|
||||
c.mu.Lock()
|
||||
c.baseURL = baseURL
|
||||
c.email = email
|
||||
c.password = password
|
||||
c.token = ""
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// APIError carries the HTTP status and body from a failed PocketBase call.
|
||||
type APIError struct {
|
||||
Status int
|
||||
@@ -49,9 +85,10 @@ func (e *APIError) Error() string {
|
||||
// Authenticate obtains a superuser token. It tries the PocketBase v0.23+
|
||||
// (_superusers collection) endpoint first, then the legacy admins endpoint.
|
||||
func (c *Client) Authenticate(ctx context.Context) error {
|
||||
baseURL, email, password := c.creds()
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"identity": c.email,
|
||||
"password": c.password,
|
||||
"identity": email,
|
||||
"password": password,
|
||||
})
|
||||
|
||||
endpoints := []string{
|
||||
@@ -61,7 +98,7 @@ func (c *Client) Authenticate(ctx context.Context) error {
|
||||
|
||||
var lastErr error
|
||||
for _, ep := range endpoints {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+ep, bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -129,7 +166,7 @@ func (c *Client) attempt(ctx context.Context, method, path string, payload any)
|
||||
reader = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL()+path, reader)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -149,6 +186,108 @@ func (c *Client) attempt(ctx context.Context, method, path string, payload any)
|
||||
return raw, resp.StatusCode, err
|
||||
}
|
||||
|
||||
// Raw performs a superuser request and returns the upstream body and status
|
||||
// WITHOUT translating a non-2xx into an error. Handlers that want to relay
|
||||
// PocketBase's own validation errors to the client verbatim (user/organization
|
||||
// management) use this; handlers that want Go errors use the typed CRUD helpers.
|
||||
func (c *Client) Raw(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
|
||||
raw, status, err := c.attempt(ctx, method, path, payload)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
if err := c.Authenticate(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return c.attempt(ctx, method, path, payload)
|
||||
}
|
||||
return raw, status, nil
|
||||
}
|
||||
|
||||
// AuthRefresh validates an end user's auth token against PocketBase and returns
|
||||
// the refreshed auth response body and status. Unlike the superuser calls this
|
||||
// carries the *caller's* token, not the service account's — it is how the server
|
||||
// resolves who a request belongs to.
|
||||
func (c *Client) AuthRefresh(ctx context.Context, collection, token string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.BaseURL()+"/api/collections/"+collection+"/auth-refresh", nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
return raw, resp.StatusCode, err
|
||||
}
|
||||
|
||||
// LoginWithPassword forwards a login to PocketBase's auth-with-password and
|
||||
// returns its response body and status untouched, so the caller can relay both
|
||||
// (token + record) straight back to the client.
|
||||
func (c *Client) LoginWithPassword(ctx context.Context, collection, identity, password string) ([]byte, int, error) {
|
||||
body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
c.BaseURL()+"/api/collections/"+collection+"/auth-with-password", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
return raw, resp.StatusCode, err
|
||||
}
|
||||
|
||||
// SuperuserAuth performs a one-off superuser auth-with-password against an
|
||||
// arbitrary PocketBase and returns the HTTP status. It shares no state with any
|
||||
// Client, so the settings endpoints can test a *candidate* connection before
|
||||
// applying it. Both the v0.23+ (_superusers) and legacy (admins) endpoints are
|
||||
// tried, matching Client.Authenticate.
|
||||
func SuperuserAuth(ctx context.Context, httpClient *http.Client, baseURL, email, password string) (int, error) {
|
||||
body, _ := json.Marshal(map[string]string{"identity": email, "password": password})
|
||||
|
||||
var lastStatus int
|
||||
var lastErr error
|
||||
for _, ep := range []string{
|
||||
"/api/collections/_superusers/auth-with-password",
|
||||
"/api/admins/auth-with-password",
|
||||
} {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil || out.Token == "" {
|
||||
return resp.StatusCode, fmt.Errorf("superuser auth: no token in response")
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
lastStatus = resp.StatusCode
|
||||
lastErr = &APIError{Status: resp.StatusCode, Body: string(raw)}
|
||||
}
|
||||
return lastStatus, lastErr
|
||||
}
|
||||
|
||||
// AuthRecord is the user record returned by a successful password auth.
|
||||
type AuthRecord struct {
|
||||
ID string `json:"id"`
|
||||
@@ -161,7 +300,7 @@ type AuthRecord struct {
|
||||
// the superuser token. Returns the matched user record on success.
|
||||
func (c *Client) AuthWithPassword(ctx context.Context, collection, identity, password string) (*AuthRecord, error) {
|
||||
body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
|
||||
url := c.baseURL + "/api/collections/" + collection + "/auth-with-password"
|
||||
url := c.BaseURL() + "/api/collections/" + collection + "/auth-with-password"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -277,7 +416,7 @@ func (c *Client) GetFile(ctx context.Context, collection, recordID, filename str
|
||||
}
|
||||
|
||||
func (c *Client) attemptGetFile(ctx context.Context, path string) ([]byte, string, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL()+path, nil)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
@@ -340,7 +479,7 @@ func (c *Client) attemptMultipart(ctx context.Context, collection, id string, fi
|
||||
return 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+"/api/collections/"+collection+"/records/"+id, &buf)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL()+"/api/collections/"+collection+"/records/"+id, &buf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# Building DriverVault Plugins
|
||||
|
||||
A **plugin** integrates an external third-party service (vehicle data, parts
|
||||
catalogs, notifications, file storage, …) behind one uniform contract. There are
|
||||
two kinds:
|
||||
|
||||
| Kind | Written as | Added by | Rebuild? | Use when |
|
||||
|---|---|---|---|---|
|
||||
| **built-in** | Go code in this repo | a rebuild | yes | first-party, high-trust, type-safe connectors |
|
||||
| **external** | any HTTP service | registering a URL at runtime | **no** | third-party / less-trusted / independently deployed |
|
||||
|
||||
Both implement the same behaviour; the server treats them identically. Enable
|
||||
state and per-plugin config persist to `plugins.json` and load on boot. Every
|
||||
plugin is managed by a **superadmin** from the panel (`/`) or the
|
||||
`/api/admin/plugins*` API.
|
||||
|
||||
---
|
||||
|
||||
## The contract
|
||||
|
||||
All plugins satisfy the Go interface in [`plugin.go`](plugin.go):
|
||||
|
||||
```go
|
||||
type Plugin interface {
|
||||
Descriptor() Descriptor
|
||||
Init(ctx context.Context, config map[string]string) error
|
||||
HealthCheck(ctx context.Context) Health
|
||||
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
```
|
||||
|
||||
- **`Descriptor`** — static metadata (name, provider, version, capabilities,
|
||||
auth type, config fields). Drives the panel UI.
|
||||
- **`Init`** — called with the resolved config (secrets included) whenever the
|
||||
plugin is enabled or its config changes. Prepare clients/tokens here.
|
||||
- **`HealthCheck`** — probe the upstream and classify: `Health{Status, LatencyMs, Detail}`
|
||||
where `Status` is `StatusOK` / `StatusDegraded` / `StatusDown`.
|
||||
- **`Invoke`** — run a named capability. **Part of the contract for the future;
|
||||
no HTTP endpoint exposes it in v1.** Implement it anyway so the connector is
|
||||
ready.
|
||||
- **`Shutdown`** — release resources.
|
||||
|
||||
### Descriptor & config fields
|
||||
|
||||
```go
|
||||
Descriptor{
|
||||
Name: "acme", // unique id, [a-z0-9-]
|
||||
Provider: "ACME Corp", // human label
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin, // or KindExternal
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
|
||||
},
|
||||
AuthType: plugins.AuthAPIKey, // None | APIKey | Basic | OAuth2 | Webhook (metadata only)
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true,
|
||||
Help: "Found under ACME → Settings → API."},
|
||||
{Key: "region", Label: "Region", Type: "text", Help: "e.g. eu-west-1"},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`ConfigField.Type` is `"text"`, `"password"`, or `"number"` (form input hint).
|
||||
Set **`Secret: true`** for credentials — the server never echoes them back in
|
||||
clear; the panel shows a mask (`••••••••`), and on save a field left at the mask
|
||||
keeps its stored value (so operators don't retype secrets). **`Required: true`**
|
||||
fields must be non-empty before the plugin can be enabled.
|
||||
|
||||
---
|
||||
|
||||
## Building a built-in plugin
|
||||
|
||||
1. **Create a package** under `internal/plugins/builtin/<name>/`.
|
||||
2. **Implement `Plugin`** and **register it in `init()`**.
|
||||
3. **Blank-import** your package from [`builtin/builtin.go`](builtin/builtin.go).
|
||||
4. **Rebuild** the server.
|
||||
|
||||
### Minimal example — `internal/plugins/builtin/acme/acme.go`
|
||||
|
||||
```go
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("acme", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
apiKey string
|
||||
region string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "acme", Provider: "ACME Corp", Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin, AuthType: plugins.AuthAPIKey,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
|
||||
},
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true},
|
||||
{Key: "region", Label: "Region", Type: "text"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.apiKey = strings.TrimSpace(config["apiKey"])
|
||||
p.region = strings.TrimSpace(config["region"])
|
||||
p.client = &http.Client{Timeout: 10 * time.Second}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.acme.example/ping", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
resp, err := p.client.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: "reachable"}
|
||||
}
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: "HTTP " + resp.Status}
|
||||
}
|
||||
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
// Implement your capabilities; return normalized JSON. (Not yet called in v1.)
|
||||
return json.RawMessage(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
```
|
||||
|
||||
### Register it for compilation — `internal/plugins/builtin/builtin.go`
|
||||
|
||||
```go
|
||||
import (
|
||||
_ "drivervault/apiserver/internal/plugins/builtin/acme"
|
||||
)
|
||||
```
|
||||
|
||||
### Rebuild
|
||||
|
||||
```powershell
|
||||
cd "API Server"
|
||||
go build -o bin/api-server.exe ./cmd/server
|
||||
```
|
||||
|
||||
Restart the server. The plugin appears in the panel's **Plugins** card,
|
||||
**disabled** by default.
|
||||
|
||||
> DriverVault ships no built-in connectors yet, so `builtin/builtin.go` has an
|
||||
> empty import block. The **external** kind below needs no rebuild and is the
|
||||
> easier place to start.
|
||||
|
||||
---
|
||||
|
||||
## Building an external plugin (no rebuild)
|
||||
|
||||
An external plugin is **any HTTP service** you host (Go recommended, but any
|
||||
language works). You register its base URL at runtime; the server drives it over
|
||||
a tiny JSON contract.
|
||||
|
||||
### The HTTP contract
|
||||
|
||||
| Method & path | Purpose | Response |
|
||||
|---|---|---|
|
||||
| `GET {base}/manifest` | describe the plugin (optional) | `{provider, version, capabilities, authType, configFields}` |
|
||||
| `GET {base}/health` | health probe (required) | `2xx` = healthy; optional body `{status, detail}` |
|
||||
| `POST {base}/invoke` | run a capability (optional; unused in v1) | `{action, params}` in → arbitrary JSON out |
|
||||
|
||||
Health rules the server applies: transport error or `5xx` → `down`; `2xx` → `ok`;
|
||||
anything else → `degraded`. An explicit `{"status":"ok|degraded|down","detail":"…"}`
|
||||
body overrides the status-code heuristic. Bodies are size-limited (health 64 KiB,
|
||||
manifest 1 MiB).
|
||||
|
||||
### Minimal example — a Go plugin service
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"provider": "ACME Cloud",
|
||||
"version": "2.1.0",
|
||||
"authType": "apikey",
|
||||
"capabilities": []map[string]any{
|
||||
{"id": "widgets.list", "method": "GET", "endpoint": "/widgets", "description": "List widgets."},
|
||||
}, // a plain []string{"widgets.list"} is also accepted
|
||||
"configFields": []map[string]any{
|
||||
{"key": "apiKey", "label": "API key", "type": "password", "required": true, "secret": true},
|
||||
},
|
||||
})
|
||||
})
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "detail": "acme cloud reachable"})
|
||||
})
|
||||
http.HandleFunc("/invoke", func(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Action string `json:"action"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&in)
|
||||
json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": in.Action})
|
||||
})
|
||||
http.ListenAndServe(":9100", nil)
|
||||
}
|
||||
```
|
||||
|
||||
### Register it
|
||||
|
||||
From the panel's **Plugins** card → *Register external plugin* (name + base URL),
|
||||
or via the API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/admin/plugins \
|
||||
-H "Authorization: $SUPERADMIN_TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"name":"acme-cloud","baseURL":"http://127.0.0.1:9100","provider":"ACME Cloud"}'
|
||||
```
|
||||
|
||||
It starts **disabled**; enable it and run a health check from the panel. Because
|
||||
it runs as its own process/container, an external plugin is also the
|
||||
**sandboxing** path for less-trusted integrations.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle, config & secrets
|
||||
|
||||
- **Enable/disable** and **config** persist to `plugins.json` (gitignored; override
|
||||
the path with `PLUGINS_FILE`). Enabling calls `Init`; disabling calls `Shutdown`.
|
||||
- **Secrets** (`Secret: true` fields) are returned masked. On save, a field still
|
||||
equal to the mask keeps its stored value; send a new value to change it, or an
|
||||
empty string to clear it.
|
||||
- **Required** fields are validated when enabling — enabling fails with a clear
|
||||
error if one is blank.
|
||||
- If `Init` fails (e.g. bad credentials), the state is still saved and the API
|
||||
returns the plugin plus a `warning`; fix the config and re-save.
|
||||
|
||||
---
|
||||
|
||||
## Managing plugins (superadmin API)
|
||||
|
||||
All endpoints require a superadmin bearer token (`Authorization: <token>` from
|
||||
`POST /api/auth/login`). See the panel's **Management API** reference too.
|
||||
|
||||
| Method | Path | Body | Purpose |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/admin/plugins` | — | list all plugins + state + last health |
|
||||
| `GET` | `/api/admin/plugins/{name}` | — | one plugin |
|
||||
| `PUT` | `/api/admin/plugins/{name}` | `{enabled?, config?}` | enable/disable + configure |
|
||||
| `POST` | `/api/admin/plugins` | `{name, baseURL, provider?}` | register an external plugin |
|
||||
| `DELETE` | `/api/admin/plugins/{name}` | — | remove an external plugin (built-ins only disable) |
|
||||
| `POST` | `/api/admin/plugins/{name}/health` | — | run a health check now |
|
||||
|
||||
---
|
||||
|
||||
## Testing your plugin
|
||||
|
||||
1. Build + restart (built-in) or start your service (external) and register it.
|
||||
2. `GET /api/admin/plugins` → confirm your descriptor, config fields, capabilities.
|
||||
3. `PUT /api/admin/plugins/{name} {"enabled":true, "config":{…}}` → enable with config.
|
||||
4. `POST /api/admin/plugins/{name}/health` → confirm the live probe classifies correctly.
|
||||
5. Restart the server → confirm state reloads from `plugins.json`.
|
||||
|
||||
A Go unit test can exercise a built-in directly:
|
||||
|
||||
```go
|
||||
p := &acme.Plugin{}
|
||||
_ = p.Init(context.Background(), map[string]string{"apiKey": "test"})
|
||||
if h := p.HealthCheck(context.Background()); h.Status == "" {
|
||||
t.Fatal("expected a health status")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Not yet implemented (roadmap)
|
||||
|
||||
The contract is shaped for these; see [`doc.go`](doc.go):
|
||||
|
||||
- **Invocation API** — an endpoint to call `Invoke` from clients, with a normalized
|
||||
request/response envelope and a provider→internal mapper.
|
||||
- **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics.
|
||||
- **Per-tenant credentials** — config keyed by org/user so users connect their own accounts.
|
||||
- **Audit logging** of plugin access.
|
||||
|
||||
Until the invocation API lands, `Invoke` is dormant — plugins are discoverable,
|
||||
configurable, and health-checked, but not yet callable over HTTP.
|
||||
@@ -0,0 +1,12 @@
|
||||
// Package builtin blank-imports every built-in plugin so their init() functions
|
||||
// register them with the plugin registry. Import this package once (from the api
|
||||
// package) to make all built-in connectors available.
|
||||
//
|
||||
// DriverVault ships no built-in connectors yet — add one under
|
||||
// internal/plugins/builtin/<name>/ and blank-import it here, e.g.
|
||||
//
|
||||
// import _ "drivervault/apiserver/internal/plugins/builtin/acme"
|
||||
//
|
||||
// Until then, plugins are added at runtime as the "external" HTTP kind, which
|
||||
// needs no rebuild. See ../README.md.
|
||||
package builtin
|
||||
@@ -0,0 +1,20 @@
|
||||
package plugins
|
||||
|
||||
// Deferred extension points (deliberately NOT in v1 — the "Management MVP").
|
||||
// The contract and manager are shaped so these can be added without a redesign:
|
||||
//
|
||||
// - Invocation API: the Plugin.Invoke method already exists; a
|
||||
// POST /api/admin/plugins/{name}/action endpoint + a normalized request/
|
||||
// response envelope would expose it. Add a mapper layer so core logic never
|
||||
// depends on a provider's schema.
|
||||
// - Resilience: wrap plugin calls with retry/backoff + a circuit breaker, and
|
||||
// record per-plugin latency/error/quota metrics for the panel.
|
||||
// - Per-tenant credentials: today config is a single global blob per plugin.
|
||||
// A (pluginName, orgID/userID) → config store would let users connect their
|
||||
// own third-party accounts.
|
||||
// - Audit logging: record which plugin accessed what and when.
|
||||
// - Sandboxing: the "external" plugin kind is the isolation story — run less
|
||||
// trusted plugins as separate processes/containers behind the HTTP contract.
|
||||
// - Hot-adding builtin Go code without a rebuild is intentionally unsupported
|
||||
// (Go .so plugins are Linux-only and toolchain-fragile); use the external
|
||||
// HTTP kind to add plugins at runtime instead.
|
||||
@@ -0,0 +1,149 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// externalPlugin adapts a remote HTTP service to the Plugin contract. The remote
|
||||
// side implements a tiny JSON contract:
|
||||
//
|
||||
// GET {baseURL}/manifest → { provider, version, capabilities, authType, configFields }
|
||||
// GET {baseURL}/health → 2xx, optionally { status, detail }
|
||||
// POST {baseURL}/invoke → { action, params } → arbitrary JSON (v1: unused)
|
||||
//
|
||||
// This is the "add a plugin without a rebuild" path: register a base URL at
|
||||
// runtime and the server drives it over HTTP. It is also the sandboxing story —
|
||||
// a less-trusted plugin runs as its own process/container.
|
||||
type externalPlugin struct {
|
||||
name string
|
||||
baseURL string
|
||||
desc Descriptor
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newExternalPlugin(name, baseURL, provider string) *externalPlugin {
|
||||
if provider == "" {
|
||||
provider = "External"
|
||||
}
|
||||
return &externalPlugin{
|
||||
name: name,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{Timeout: 8 * time.Second},
|
||||
desc: Descriptor{
|
||||
Name: name,
|
||||
Provider: provider,
|
||||
Version: "external",
|
||||
Kind: KindExternal,
|
||||
Category: CategoryAPIsExternal, // remote HTTP service; a manifest may override
|
||||
AuthType: AuthNone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *externalPlugin) Descriptor() Descriptor { return e.desc }
|
||||
|
||||
// Init best-effort fetches the remote manifest to enrich the descriptor. A
|
||||
// missing/broken manifest is non-fatal — the basic descriptor stands.
|
||||
func (e *externalPlugin) Init(ctx context.Context, _ map[string]string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/manifest", nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var man struct {
|
||||
Provider string `json:"provider"`
|
||||
Version string `json:"version"`
|
||||
Category string `json:"category"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
AuthType AuthType `json:"authType"`
|
||||
ConfigFields []ConfigField `json:"configFields"`
|
||||
}
|
||||
if json.Unmarshal(data, &man) == nil {
|
||||
if man.Provider != "" {
|
||||
e.desc.Provider = man.Provider
|
||||
}
|
||||
if man.Version != "" {
|
||||
e.desc.Version = man.Version
|
||||
}
|
||||
if man.AuthType != "" {
|
||||
e.desc.AuthType = man.AuthType
|
||||
}
|
||||
if man.Category != "" {
|
||||
e.desc.Category = man.Category
|
||||
}
|
||||
e.desc.Capabilities = man.Capabilities
|
||||
e.desc.ConfigFields = man.ConfigFields
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *externalPlugin) HealthCheck(ctx context.Context) Health {
|
||||
start := time.Now()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return Health{Status: StatusDown, Detail: err.Error()}
|
||||
}
|
||||
resp, err := e.client.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return Health{Status: StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
|
||||
// Honour an explicit {status, detail} body when present.
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
_ = json.Unmarshal(data, &body)
|
||||
|
||||
h := Health{LatencyMs: lat, Detail: body.Detail}
|
||||
switch {
|
||||
case body.Status != "":
|
||||
h.Status = body.Status
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
h.Status = StatusOK
|
||||
case resp.StatusCode >= 500:
|
||||
h.Status = StatusDown
|
||||
default:
|
||||
h.Status = StatusDegraded
|
||||
}
|
||||
if h.Detail == "" && h.Status != StatusOK {
|
||||
h.Detail = "HTTP " + resp.Status
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Invoke proxies to the remote /invoke endpoint. Part of the contract; no HTTP
|
||||
// endpoint exposes it in v1.
|
||||
func (e *externalPlugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
|
||||
payload, _ := json.Marshal(map[string]any{"action": action, "params": params})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/invoke", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := e.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (e *externalPlugin) Shutdown(context.Context) error { return nil }
|
||||
@@ -0,0 +1,346 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// secretMask is what a set secret value is echoed back as. On save, a field that
|
||||
// still equals the mask is left unchanged (mirrors the pb-config password flow).
|
||||
const secretMask = "••••••••"
|
||||
|
||||
// record is the persisted state for one plugin. For builtins, Kind/BaseURL are
|
||||
// omitted (the descriptor comes from the registry); external plugins set them.
|
||||
type record struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]string `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// View is the plugin shape returned to the panel (secrets masked).
|
||||
type View struct {
|
||||
Descriptor
|
||||
Enabled bool `json:"enabled"`
|
||||
Config map[string]string `json:"config"`
|
||||
BaseURL string `json:"baseURL,omitempty"`
|
||||
Health *Health `json:"health,omitempty"`
|
||||
}
|
||||
|
||||
// Manager owns the plugin registry, persisted state, and live instances.
|
||||
type Manager struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
factories map[string]Factory
|
||||
records map[string]*record
|
||||
live map[string]Plugin
|
||||
health map[string]*Health
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewManager builds a Manager backed by the JSON state file at path.
|
||||
func NewManager(path string) *Manager {
|
||||
return &Manager{
|
||||
path: path,
|
||||
factories: builtinFactories(),
|
||||
records: map[string]*record{},
|
||||
live: map[string]Plugin{},
|
||||
health: map[string]*Health{},
|
||||
client: &http.Client{Timeout: 12 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the state file and initialises every enabled plugin. A missing file
|
||||
// is fine (no plugins configured yet).
|
||||
func (m *Manager) Load() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if data, err := os.ReadFile(m.path); err == nil {
|
||||
var recs map[string]*record
|
||||
if err := json.Unmarshal(data, &recs); err != nil {
|
||||
return err
|
||||
}
|
||||
m.records = recs
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
for name, rec := range m.records {
|
||||
if !rec.Enabled {
|
||||
continue
|
||||
}
|
||||
p := construct(name, m.factories[name], rec)
|
||||
if p == nil {
|
||||
log.Printf("plugins: cannot construct %q (unknown builtin?)", name)
|
||||
continue
|
||||
}
|
||||
if err := p.Init(ctx, rec.Config); err != nil {
|
||||
log.Printf("plugins: init %q failed: %v", name, err)
|
||||
continue
|
||||
}
|
||||
m.live[name] = p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// construct builds a plugin instance from a builtin factory or an external record.
|
||||
func construct(name string, f Factory, rec *record) Plugin {
|
||||
if f != nil {
|
||||
return f()
|
||||
}
|
||||
if rec != nil && rec.Kind == KindExternal {
|
||||
return newExternalPlugin(name, rec.BaseURL, rec.Provider)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// descriptorFor returns a plugin's descriptor without needing a live instance.
|
||||
func (m *Manager) descriptorFor(name string, rec *record) Descriptor {
|
||||
if p := m.live[name]; p != nil {
|
||||
return p.Descriptor()
|
||||
}
|
||||
if f := m.factories[name]; f != nil {
|
||||
return f().Descriptor()
|
||||
}
|
||||
if rec != nil && rec.Kind == KindExternal {
|
||||
return newExternalPlugin(name, rec.BaseURL, rec.Provider).Descriptor()
|
||||
}
|
||||
return Descriptor{Name: name}
|
||||
}
|
||||
|
||||
// maskConfig echoes config back with secret fields masked when set.
|
||||
func maskConfig(d Descriptor, cfg map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Secret && out[f.Key] != "" {
|
||||
out[f.Key] = secretMask
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// List returns every known plugin (registry ∪ persisted), sorted by name.
|
||||
func (m *Manager) List() []View {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
names := map[string]bool{}
|
||||
for n := range m.factories {
|
||||
names[n] = true
|
||||
}
|
||||
for n := range m.records {
|
||||
names[n] = true
|
||||
}
|
||||
|
||||
out := make([]View, 0, len(names))
|
||||
for name := range names {
|
||||
rec := m.records[name]
|
||||
d := m.descriptorFor(name, rec)
|
||||
v := View{Descriptor: d, Health: m.health[name]}
|
||||
if rec != nil {
|
||||
v.Enabled = rec.Enabled
|
||||
v.BaseURL = rec.BaseURL
|
||||
v.Config = maskConfig(d, rec.Config)
|
||||
} else {
|
||||
v.Config = map[string]string{}
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns a single plugin view (ok=false when unknown).
|
||||
func (m *Manager) Get(name string) (View, bool) {
|
||||
for _, v := range m.List() {
|
||||
if v.Name == name {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return View{}, false
|
||||
}
|
||||
|
||||
// Upsert enables/disables a plugin and merges its config, then (re)initialises or
|
||||
// shuts down the live instance to match. Secrets left at the mask are preserved.
|
||||
func (m *Manager) Upsert(ctx context.Context, name string, enabled bool, incoming map[string]string) (View, error) {
|
||||
m.mu.Lock()
|
||||
|
||||
_, isBuiltin := m.factories[name]
|
||||
rec := m.records[name]
|
||||
if !isBuiltin && (rec == nil || rec.Kind != KindExternal) {
|
||||
m.mu.Unlock()
|
||||
return View{}, errUnknown
|
||||
}
|
||||
if rec == nil {
|
||||
rec = &record{}
|
||||
m.records[name] = rec
|
||||
}
|
||||
|
||||
d := m.descriptorFor(name, rec)
|
||||
merged := map[string]string{}
|
||||
for k, v := range rec.Config {
|
||||
merged[k] = v
|
||||
}
|
||||
// Apply incoming values, honouring the secret-mask keep-current rule.
|
||||
secretKeys := map[string]bool{}
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Secret {
|
||||
secretKeys[f.Key] = true
|
||||
}
|
||||
}
|
||||
for k, v := range incoming {
|
||||
if secretKeys[k] && v == secretMask {
|
||||
continue // keep existing secret
|
||||
}
|
||||
merged[k] = strings.TrimSpace(v)
|
||||
}
|
||||
// Validate required fields when enabling.
|
||||
if enabled {
|
||||
for _, f := range d.ConfigFields {
|
||||
if f.Required && merged[f.Key] == "" {
|
||||
m.mu.Unlock()
|
||||
return View{}, errors.New("missing required setting: " + f.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rec.Enabled = enabled
|
||||
rec.Config = merged
|
||||
if err := m.persistLocked(); err != nil {
|
||||
m.mu.Unlock()
|
||||
return View{}, err
|
||||
}
|
||||
|
||||
// Reconcile the live instance.
|
||||
if old := m.live[name]; old != nil {
|
||||
_ = old.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
var initErr error
|
||||
if enabled {
|
||||
p := construct(name, m.factories[name], rec)
|
||||
if p != nil {
|
||||
if err := p.Init(ctx, merged); err != nil {
|
||||
initErr = err
|
||||
} else {
|
||||
m.live[name] = p
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
v, _ := m.Get(name)
|
||||
return v, initErr
|
||||
}
|
||||
|
||||
// RegisterExternal adds a new external (remote HTTP) plugin at runtime — the
|
||||
// "add a plugin without a rebuild" path. It starts disabled.
|
||||
func (m *Manager) RegisterExternal(name, baseURL, provider string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if name == "" || baseURL == "" {
|
||||
return errors.New("name and baseURL are required")
|
||||
}
|
||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
baseURL = "http://" + baseURL
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, dup := m.factories[name]; dup {
|
||||
return errors.New("a builtin plugin already uses that name")
|
||||
}
|
||||
if _, dup := m.records[name]; dup {
|
||||
return errors.New("a plugin with that name already exists")
|
||||
}
|
||||
m.records[name] = &record{Kind: KindExternal, BaseURL: baseURL, Provider: provider}
|
||||
return m.persistLocked()
|
||||
}
|
||||
|
||||
// Remove deletes an external plugin registration. Builtins can only be disabled.
|
||||
func (m *Manager) Remove(ctx context.Context, name string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
rec := m.records[name]
|
||||
if rec == nil || rec.Kind != KindExternal {
|
||||
return errors.New("only external plugins can be removed")
|
||||
}
|
||||
if p := m.live[name]; p != nil {
|
||||
_ = p.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
delete(m.records, name)
|
||||
delete(m.health, name)
|
||||
return m.persistLocked()
|
||||
}
|
||||
|
||||
// HealthCheck probes a plugin now, building a transient instance if it is not
|
||||
// currently live (so disabled plugins can still be tested). Result is cached.
|
||||
func (m *Manager) HealthCheck(ctx context.Context, name string) (Health, error) {
|
||||
m.mu.Lock()
|
||||
p := m.live[name]
|
||||
transient := false
|
||||
var cfg map[string]string
|
||||
if p == nil {
|
||||
rec := m.records[name]
|
||||
if rec != nil {
|
||||
cfg = rec.Config
|
||||
}
|
||||
p = construct(name, m.factories[name], rec)
|
||||
transient = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if p == nil {
|
||||
return Health{}, errUnknown
|
||||
}
|
||||
if transient {
|
||||
_ = p.Init(ctx, cfg)
|
||||
defer func() { _ = p.Shutdown(context.Background()) }()
|
||||
}
|
||||
h := p.HealthCheck(ctx)
|
||||
|
||||
m.mu.Lock()
|
||||
hc := h
|
||||
m.health[name] = &hc
|
||||
m.mu.Unlock()
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// Shutdown tears down every live plugin instance. Wire into graceful shutdown.
|
||||
func (m *Manager) Shutdown(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for name, p := range m.live {
|
||||
_ = p.Shutdown(ctx)
|
||||
delete(m.live, name)
|
||||
}
|
||||
}
|
||||
|
||||
// persistLocked writes the state file. Caller must hold m.mu.
|
||||
func (m *Manager) persistLocked() error {
|
||||
data, err := json.MarshalIndent(m.records, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(m.path, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
var errUnknown = errors.New("unknown plugin")
|
||||
|
||||
// IsUnknown reports whether err came from addressing a plugin that doesn't exist.
|
||||
func IsUnknown(err error) bool { return errors.Is(err, errUnknown) }
|
||||
@@ -0,0 +1,181 @@
|
||||
// Package plugins is the API Server's plugin system: a uniform contract for
|
||||
// integrating external third-party services (vehicle data, parts catalogs,
|
||||
// notifications, file storage, …).
|
||||
//
|
||||
// Two plugin kinds share one contract:
|
||||
// - "builtin" — a Go connector compiled into the server (type-safe, first-party).
|
||||
// Adding a new builtin requires a rebuild. See builtin/builtin.go.
|
||||
// - "external" — a remote service registered at runtime (no rebuild) that speaks
|
||||
// a small JSON contract over HTTP. See external.go.
|
||||
//
|
||||
// Enable-state and per-plugin config (including secrets) are persisted to a local
|
||||
// plugins.json by the Manager, mirroring how the PocketBase connection persists to
|
||||
// .env. See doc.go for the deliberately-deferred extension points.
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// Plugin kinds.
|
||||
const (
|
||||
KindBuiltin = "builtin"
|
||||
KindExternal = "external"
|
||||
)
|
||||
|
||||
// AuthType describes how a plugin authenticates to its upstream. It is metadata
|
||||
// for the UI/operators; each plugin implements the mechanics itself.
|
||||
type AuthType string
|
||||
|
||||
const (
|
||||
AuthNone AuthType = "none"
|
||||
AuthAPIKey AuthType = "apikey"
|
||||
AuthBasic AuthType = "basic"
|
||||
AuthOAuth2 AuthType = "oauth2"
|
||||
AuthWebhook AuthType = "webhook"
|
||||
)
|
||||
|
||||
// Health status values.
|
||||
const (
|
||||
StatusOK = "ok"
|
||||
StatusDegraded = "degraded"
|
||||
StatusDown = "down"
|
||||
)
|
||||
|
||||
// SelectOption is one choice for a ConfigField of Type "select".
|
||||
type SelectOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// ConfigField declares one configurable setting a plugin accepts. It drives the
|
||||
// panel's generated config form and controls secret masking.
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"` // "text" | "password" | "number" | "select"
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"` // never echoed back to clients in clear
|
||||
Help string `json:"help,omitempty"`
|
||||
Default string `json:"default,omitempty"` // effective default when unset
|
||||
Options []SelectOption `json:"options,omitempty"` // for Type "select"
|
||||
}
|
||||
|
||||
// Capability is one operation a plugin exposes. It maps a stable id to the
|
||||
// upstream endpoint it calls and a human description shown in the panel.
|
||||
type Capability struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method,omitempty"` // e.g. "GET"
|
||||
Endpoint string `json:"endpoint,omitempty"` // upstream path, e.g. "/states/all"
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts either a bare string ("states.all") or a full object, so
|
||||
// external manifests can advertise capabilities in either form.
|
||||
func (c *Capability) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if json.Unmarshal(b, &s) == nil {
|
||||
c.ID = s
|
||||
return nil
|
||||
}
|
||||
type alias Capability
|
||||
var a alias
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = Capability(a)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Category groups a plugin under a tab in the admin panel. A plugin with an
|
||||
// empty category is treated as CategoryAPIsExternal by the panel.
|
||||
const (
|
||||
CategoryAPIsExternal = "apis-external" // remote HTTP APIs (external plugins)
|
||||
CategoryDrivesExternal = "drives-external" // remote file stores (FTP/SFTP)
|
||||
CategoryDrivesLocal = "drives-local" // drives on the host machine
|
||||
)
|
||||
|
||||
// Descriptor is the static metadata a plugin advertises about itself.
|
||||
type Descriptor struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Version string `json:"version"`
|
||||
Kind string `json:"kind"` // KindBuiltin | KindExternal
|
||||
Category string `json:"category"` // one of Category* — groups the plugin in the panel
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
AuthType AuthType `json:"authType"`
|
||||
ConfigFields []ConfigField `json:"configFields"`
|
||||
}
|
||||
|
||||
// Health is the outcome of a plugin's HealthCheck.
|
||||
type Health struct {
|
||||
Status string `json:"status"` // StatusOK | StatusDegraded | StatusDown
|
||||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Credits *HealthCredits `json:"credits,omitempty"`
|
||||
Usage *HealthUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// HealthUsage is optional call-usage accounting a plugin may report when its
|
||||
// upstream does NOT expose remaining quota (e.g. OpenWeather). Unlike
|
||||
// HealthCredits — which reflects a balance the upstream reports — these are
|
||||
// process-local counts of the calls this server has made, bucketed into the
|
||||
// current minute and day, so the UI can render an approximate usage gauge.
|
||||
type HealthUsage struct {
|
||||
MinuteUsed int `json:"minuteUsed"` // calls made in the current minute
|
||||
MinuteLimit int `json:"minuteLimit,omitempty"` // the plan's per-minute limit
|
||||
DayUsed int `json:"dayUsed"` // calls made so far today (UTC)
|
||||
}
|
||||
|
||||
// HealthCredits is optional structured rate-limit/credit accounting a plugin may
|
||||
// report alongside a probe, when its upstream exposes a remaining balance. It
|
||||
// lets the UI render a dedicated usage meter instead of parsing it back out of
|
||||
// Detail.
|
||||
type HealthCredits struct {
|
||||
Remaining *int `json:"remaining,omitempty"` // credits left today; nil when the upstream didn't report it (e.g. anonymous)
|
||||
Daily int `json:"daily,omitempty"` // the plan's daily allowance
|
||||
ProbeCost int `json:"probeCost,omitempty"` // credits one query/probe costs
|
||||
Mode string `json:"mode,omitempty"` // "authenticated" | "anonymous"
|
||||
}
|
||||
|
||||
// Plugin is the contract every plugin (builtin or external) implements.
|
||||
type Plugin interface {
|
||||
// Descriptor returns the plugin's static metadata. It may be enriched after
|
||||
// Init (e.g. an external plugin fetching its manifest).
|
||||
Descriptor() Descriptor
|
||||
// Init prepares the plugin with its resolved config (secrets included). It is
|
||||
// called when the plugin is enabled or its config changes.
|
||||
Init(ctx context.Context, config map[string]string) error
|
||||
// HealthCheck probes the upstream and classifies the result.
|
||||
HealthCheck(ctx context.Context) Health
|
||||
// Invoke runs a named capability. Part of the contract for future use; v1
|
||||
// exposes no HTTP endpoint for it.
|
||||
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
|
||||
// Shutdown releases any resources held by the plugin.
|
||||
Shutdown(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Factory builds a fresh instance of a builtin plugin.
|
||||
type Factory func() Plugin
|
||||
|
||||
// registry holds the builtin plugin factories keyed by descriptor name.
|
||||
var registry = map[string]Factory{}
|
||||
|
||||
// Register adds a builtin plugin factory. Called from a builtin package's init().
|
||||
// Panics on a duplicate name so wiring mistakes surface at startup.
|
||||
func Register(name string, f Factory) {
|
||||
if _, dup := registry[name]; dup {
|
||||
panic("plugins: duplicate registration for " + name)
|
||||
}
|
||||
registry[name] = f
|
||||
}
|
||||
|
||||
// builtinFactories returns a copy of the registered builtin factories.
|
||||
func builtinFactories() map[string]Factory {
|
||||
out := make(map[string]Factory, len(registry))
|
||||
for k, v := range registry {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Command server is the central Car Control API Server. It is the single
|
||||
// gateway between clients (web app, phone app, Home Assistant, ESP32 device)
|
||||
// and the PocketBase database.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/api"
|
||||
"carcontrol/api/internal/config"
|
||||
"carcontrol/api/internal/pb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("[api] ")
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
client := pb.New(cfg.PBURL, cfg.PBAdminEmail, cfg.PBAdminPasswd)
|
||||
|
||||
authCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Authenticate(authCtx); err != nil {
|
||||
log.Fatalf("pocketbase auth (%s): %v", cfg.PBURL, err)
|
||||
}
|
||||
log.Printf("authenticated to PocketBase at %s", cfg.PBURL)
|
||||
|
||||
if cfg.UsingDevAuthSecret() {
|
||||
log.Println("WARNING: AUTH_SECRET is unset — using an insecure development secret. Set AUTH_SECRET in production.")
|
||||
}
|
||||
|
||||
srv := api.NewServer(client, api.Options{
|
||||
CORSOrigins: cfg.CORSOrigins,
|
||||
AuthSecret: cfg.AuthSecret,
|
||||
UsersCollection: cfg.UsersCollection,
|
||||
})
|
||||
httpSrv := &http.Server{
|
||||
Addr: announcedAddr(cfg.Port),
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("listening on %s", httpSrv.Addr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Graceful shutdown on SIGINT/SIGTERM.
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
log.Println("shutting down...")
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func announcedAddr(port string) string {
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
return ":" + port
|
||||
}
|
||||
+107
-73
@@ -1,42 +1,42 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { theme, toggleTheme } from "./theme";
|
||||
import { me, token, restore, logout, isManager, isSuperadmin } from "./api";
|
||||
import LoginView from "./components/LoginView.vue";
|
||||
import StatusCard from "./components/StatusCard.vue";
|
||||
import PocketBaseCard from "./components/PocketBaseCard.vue";
|
||||
import PluginsCard from "./components/PluginsCard.vue";
|
||||
import UsersCard from "./components/UsersCard.vue";
|
||||
import OrgsCard from "./components/OrgsCard.vue";
|
||||
import EndpointTable from "./components/EndpointTable.vue";
|
||||
|
||||
// Live health poll against this server.
|
||||
const status = ref("checking"); // checking | ok | error | unreachable
|
||||
const httpStatus = ref(null);
|
||||
const latency = ref(null);
|
||||
const checkedAt = ref(null);
|
||||
let timer = null;
|
||||
const booting = ref(true);
|
||||
const section = ref("overview");
|
||||
|
||||
async function check() {
|
||||
const started = performance.now();
|
||||
try {
|
||||
const r = await fetch("/api/health");
|
||||
latency.value = Math.round(performance.now() - started);
|
||||
httpStatus.value = r.status;
|
||||
status.value = r.ok ? "ok" : "error";
|
||||
} catch {
|
||||
latency.value = null;
|
||||
httpStatus.value = null;
|
||||
status.value = "unreachable";
|
||||
}
|
||||
checkedAt.value = new Date();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
check();
|
||||
timer = setInterval(check, 10000);
|
||||
onMounted(async () => {
|
||||
await restore(); // re-validate a token kept from a previous visit
|
||||
booting.value = false;
|
||||
});
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
|
||||
const badge = {
|
||||
checking: { label: "checking", cls: "bg-sunken text-muted" },
|
||||
ok: { label: "operational", cls: "bg-success-soft text-success" },
|
||||
error: { label: "error", cls: "bg-danger-soft text-danger" },
|
||||
unreachable: { label: "unreachable", cls: "bg-danger-soft text-danger" },
|
||||
};
|
||||
// Cards are gated by role: management needs a manager, PocketBase + plugins need
|
||||
// a superadmin. The server enforces the same rules — this only hides what the
|
||||
// caller could not use anyway.
|
||||
const sections = computed(() => {
|
||||
const out = [{ id: "overview", label: "Overview" }];
|
||||
if (isManager.value) {
|
||||
out.push({ id: "users", label: "Users" }, { id: "orgs", label: "Organizations" });
|
||||
}
|
||||
if (isSuperadmin.value) {
|
||||
out.push({ id: "pocketbase", label: "PocketBase" }, { id: "plugins", label: "Plugins" });
|
||||
}
|
||||
out.push({ id: "api", label: "API" });
|
||||
return out;
|
||||
});
|
||||
|
||||
function signOut() {
|
||||
logout();
|
||||
section.value = "overview";
|
||||
}
|
||||
|
||||
// Logo bar fills — flip for legibility on the dark shell.
|
||||
const barFills = computed(() =>
|
||||
@@ -45,12 +45,18 @@ const barFills = computed(() =>
|
||||
: ["var(--brand-700)", "var(--brand-500)", "var(--brand-400)"],
|
||||
);
|
||||
|
||||
// The DriverVault REST surface, grouped by resource. Mirrors the routes registered
|
||||
// in internal/api/server.go.
|
||||
// The DriverVault REST surface, grouped by resource. Mirrors the routes
|
||||
// registered in internal/api/server.go.
|
||||
const publicApi = [
|
||||
{ method: "GET", path: "/api/health", desc: "Liveness probe (no auth)" },
|
||||
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a JWT" },
|
||||
{ method: "GET", path: "/api/status", desc: "Health of PocketBase + Web App" },
|
||||
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a PocketBase token" },
|
||||
{ method: "GET", path: "/api/auth/validate", desc: "Check whether a token is still valid" },
|
||||
];
|
||||
|
||||
const identityApi = [
|
||||
{ method: "GET", path: "/api/auth/me", desc: "Identity of the bearer token" },
|
||||
{ method: "GET", path: "/api/identity", desc: "Identity incl. role + organization" },
|
||||
];
|
||||
|
||||
const carsApi = [
|
||||
@@ -97,23 +103,32 @@ const accountApi = [
|
||||
{ method: "DELETE", path: "/api/me", desc: "Finalize account deletion" },
|
||||
];
|
||||
|
||||
const sessionsApi = [
|
||||
{ method: "GET", path: "/api/sessions", desc: "List active sessions (devices)" },
|
||||
{ method: "DELETE", path: "/api/sessions/{id}", desc: "Revoke one session" },
|
||||
{ method: "DELETE", path: "/api/sessions", desc: "Revoke all other sessions" },
|
||||
const managementApi = [
|
||||
{ method: "GET", path: "/api/users", desc: "List users (scoped by role)" },
|
||||
{ method: "POST", path: "/api/users", desc: "Create a user" },
|
||||
{ method: "PATCH", path: "/api/users/{id}", desc: "Update email / name / role / org / password" },
|
||||
{ method: "DELETE", path: "/api/users/{id}", desc: "Delete a user" },
|
||||
{ method: "GET", path: "/api/orgs", desc: "List organizations" },
|
||||
{ method: "POST", path: "/api/orgs", desc: "Create an organization (superadmin)" },
|
||||
{ method: "PATCH", path: "/api/orgs/{id}", desc: "Rename an organization (superadmin)" },
|
||||
{ method: "DELETE", path: "/api/orgs/{id}", desc: "Delete an organization (superadmin)" },
|
||||
];
|
||||
|
||||
const adminApi = [
|
||||
{ method: "GET", path: "/api/admin/users", desc: "List users" },
|
||||
{ method: "POST", path: "/api/admin/users", desc: "Create a user" },
|
||||
{ method: "PATCH", path: "/api/admin/users/{id}", desc: "Update name / role" },
|
||||
{ method: "POST", path: "/api/admin/users/{id}/password", desc: "Reset a user's password" },
|
||||
{ method: "DELETE", path: "/api/admin/users/{id}", desc: "Delete a user" },
|
||||
const superadminApi = [
|
||||
{ method: "GET", path: "/api/admin/pb-config", desc: "PocketBase connection + live probe" },
|
||||
{ method: "POST", path: "/api/admin/pb-config/test", desc: "Probe a candidate connection" },
|
||||
{ method: "PUT", path: "/api/admin/pb-config", desc: "Apply + persist a connection" },
|
||||
{ method: "GET", path: "/api/admin/plugins", desc: "List plugins (secrets masked)" },
|
||||
{ method: "POST", path: "/api/admin/plugins", desc: "Register an external plugin" },
|
||||
{ method: "GET", path: "/api/admin/plugins/{name}", desc: "Fetch one plugin" },
|
||||
{ method: "PUT", path: "/api/admin/plugins/{name}", desc: "Enable/disable + configure" },
|
||||
{ method: "DELETE", path: "/api/admin/plugins/{name}", desc: "Remove an external plugin" },
|
||||
{ method: "POST", path: "/api/admin/plugins/{name}/health", desc: "Run a health check now" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-6 px-6 pt-12 pb-16">
|
||||
<div class="mx-auto flex max-w-4xl flex-col gap-6 px-6 pt-12 pb-16">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center gap-2.5 select-none">
|
||||
@@ -130,6 +145,12 @@ const adminApi = [
|
||||
</span>
|
||||
<span class="eyebrow mt-1.5">API server</span>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<span v-if="me" class="data hidden text-xs text-muted sm:inline">
|
||||
{{ me.email }}<span v-if="me.organizationName"> · {{ me.organizationName }}</span>
|
||||
</span>
|
||||
<span v-if="me" class="dh-pill bg-info-soft text-info">{{ me.role }}</span>
|
||||
|
||||
<button
|
||||
class="dh-btn-ghost"
|
||||
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||
@@ -160,37 +181,50 @@ const adminApi = [
|
||||
</svg>
|
||||
Theme
|
||||
</button>
|
||||
<button v-if="token" class="dh-btn-ghost" @click="signOut">Sign out</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="dh-card">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">Status</div>
|
||||
<span
|
||||
class="data inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-xs font-medium"
|
||||
:class="badge[status].cls"
|
||||
<p v-if="booting" class="eyebrow py-16 text-center">Loading…</p>
|
||||
|
||||
<!-- Unauthenticated: the login gate is the whole console. -->
|
||||
<LoginView v-else-if="!token" />
|
||||
|
||||
<template v-else>
|
||||
<!-- Section nav -->
|
||||
<nav class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="s in sections"
|
||||
:key="s.id"
|
||||
class="dh-btn-ghost"
|
||||
:class="section === s.id ? 'border-accent text-brandtext' : ''"
|
||||
@click="section = s.id"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
||||
{{ badge[status].label }}<template v-if="status === 'error'"> {{ httpStatus }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="data flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4 text-xs text-muted">
|
||||
<span>GET /api/health</span>
|
||||
<span>{{ latency !== null ? latency + "ms" : "—" }}</span>
|
||||
<span>{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<EndpointTable title="Public" auth="No auth" :endpoints="publicApi" />
|
||||
<EndpointTable title="Cars" auth="Bearer JWT" :endpoints="carsApi" />
|
||||
<EndpointTable title="Service records" auth="Bearer JWT" :endpoints="serviceApi" />
|
||||
<EndpointTable title="Parts" auth="Bearer JWT" :endpoints="partsApi" />
|
||||
<EndpointTable title="Account" auth="Bearer JWT" :endpoints="accountApi" />
|
||||
<EndpointTable title="Sessions" auth="Bearer JWT" :endpoints="sessionsApi" />
|
||||
<EndpointTable title="Admin" auth="Admin JWT" :endpoints="adminApi" />
|
||||
<StatusCard v-if="section === 'overview'" />
|
||||
<UsersCard v-else-if="section === 'users'" />
|
||||
<OrgsCard v-else-if="section === 'orgs'" />
|
||||
<PocketBaseCard v-else-if="section === 'pocketbase'" />
|
||||
<PluginsCard v-else-if="section === 'plugins'" />
|
||||
|
||||
<p class="eyebrow text-center">
|
||||
DriverVault — car maintenance & service tracker.
|
||||
</p>
|
||||
<template v-else-if="section === 'api'">
|
||||
<EndpointTable title="Public" auth="No auth" :endpoints="publicApi" />
|
||||
<EndpointTable title="Identity" auth="Bearer token" :endpoints="identityApi" />
|
||||
<EndpointTable title="Cars" auth="Bearer token" :endpoints="carsApi" />
|
||||
<EndpointTable title="Service records" auth="Bearer token" :endpoints="serviceApi" />
|
||||
<EndpointTable title="Parts" auth="Bearer token" :endpoints="partsApi" />
|
||||
<EndpointTable title="Account" auth="Bearer token" :endpoints="accountApi" />
|
||||
<EndpointTable title="Management" auth="Admin / superadmin" :endpoints="managementApi" />
|
||||
<EndpointTable title="Superadmin" auth="Superadmin" :endpoints="superadminApi" />
|
||||
</template>
|
||||
|
||||
<p v-if="!isManager && section === 'overview'" class="eyebrow text-center">
|
||||
Signed in as a standard user — management sections need an admin role
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p class="eyebrow text-center">DriverVault — car maintenance & service tracker.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
// The panel authenticates exactly like any other DriverVault client: it posts to
|
||||
// /api/auth/login and keeps the PocketBase token the API Server relays back. The
|
||||
// panel never talks to PocketBase directly — it doesn't even know its address.
|
||||
const TOKEN_KEY = "dh-panel-token";
|
||||
|
||||
function storedToken() {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
} catch {
|
||||
return ""; // private mode
|
||||
}
|
||||
}
|
||||
|
||||
export const token = ref(storedToken());
|
||||
export const me = ref(null);
|
||||
|
||||
export const isSuperadmin = computed(() => me.value?.role === "superadmin");
|
||||
export const isManager = computed(
|
||||
() => me.value?.role === "admin" || me.value?.role === "superadmin",
|
||||
);
|
||||
|
||||
function setToken(value) {
|
||||
token.value = value;
|
||||
try {
|
||||
if (value) localStorage.setItem(TOKEN_KEY, value);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* private mode — the session just won't survive a reload */
|
||||
}
|
||||
}
|
||||
|
||||
/** ApiError carries the HTTP status so callers can branch on 401/403/503. */
|
||||
export class ApiError extends Error {
|
||||
constructor(message, status, body) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
// messageFrom digs a human-readable message out of the several error shapes in
|
||||
// play: this server's {error}, and PocketBase's {message, data:{field:{message}}}
|
||||
// which the user/org endpoints relay verbatim.
|
||||
function messageFrom(body, status) {
|
||||
if (!body || typeof body !== "object") return `HTTP ${status}`;
|
||||
if (body.error) return body.error;
|
||||
const fieldErrors = Object.entries(body.data || {})
|
||||
.map(([field, e]) => `${field}: ${e?.message || e}`)
|
||||
.filter(Boolean);
|
||||
if (fieldErrors.length) return fieldErrors.join("; ");
|
||||
return body.message || `HTTP ${status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* request calls the API Server, attaching the panel's token. A 401 clears the
|
||||
* session so the shell falls back to the login screen.
|
||||
*/
|
||||
export async function request(path, { method = "GET", body, auth = true } = {}) {
|
||||
const headers = {};
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
if (auth && token.value) headers.Authorization = token.value;
|
||||
|
||||
const resp = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const text = await resp.text();
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = null; // non-JSON body (shouldn't happen, but don't explode on it)
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401 && auth) logout();
|
||||
throw new ApiError(messageFrom(parsed, resp.status), resp.status, parsed);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** login exchanges credentials for a PocketBase token via the API Server. */
|
||||
export async function login(email, password) {
|
||||
const out = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
auth: false,
|
||||
});
|
||||
setToken(out.token);
|
||||
await loadMe();
|
||||
return me.value;
|
||||
}
|
||||
|
||||
/** loadMe resolves the current identity, including role + organization. */
|
||||
export async function loadMe() {
|
||||
me.value = await request("/api/identity");
|
||||
return me.value;
|
||||
}
|
||||
|
||||
/** logout drops the local session. PocketBase tokens are stateless, so there is
|
||||
* nothing to revoke server-side. */
|
||||
export function logout() {
|
||||
setToken("");
|
||||
me.value = null;
|
||||
}
|
||||
|
||||
/** restore re-validates a token kept from a previous visit. */
|
||||
export async function restore() {
|
||||
if (!token.value) return null;
|
||||
try {
|
||||
return await loadMe();
|
||||
} catch {
|
||||
logout(); // expired, revoked, or the server now points at another PocketBase
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { login } from "../api";
|
||||
|
||||
const emit = defineEmits(["authenticated"]);
|
||||
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
|
||||
const canSubmit = computed(() => email.value.trim() !== "" && password.value !== "" && !busy.value);
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value) return;
|
||||
error.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
const who = await login(email.value.trim(), password.value);
|
||||
// Any DriverVault account can authenticate; the console itself is only
|
||||
// useful to a manager, and the server enforces that on every call anyway.
|
||||
emit("authenticated", who);
|
||||
} catch (e) {
|
||||
// 400/404 from PocketBase both mean "bad credentials" — don't leak which.
|
||||
error.value =
|
||||
e.status === 400 || e.status === 404
|
||||
? "Invalid email or password."
|
||||
: e.message || "Could not sign in.";
|
||||
password.value = "";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex w-full max-w-sm flex-col gap-5 pt-24">
|
||||
<div class="dh-card p-6">
|
||||
<h1 class="text-lg font-bold tracking-[-0.02em] text-strong">Sign in</h1>
|
||||
<p class="mt-1 mb-5 text-sm text-body">
|
||||
Superadmin console for the DriverVault API Server.
|
||||
</p>
|
||||
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<div>
|
||||
<label class="dh-label" for="login-email">Email</label>
|
||||
<input
|
||||
id="login-email"
|
||||
v-model="email"
|
||||
class="dh-input"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label" for="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
class="dh-input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-xs text-danger">
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<button class="dh-btn w-full" type="submit" :disabled="!canSubmit">
|
||||
{{ busy ? "Signing in…" : "Sign in" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="eyebrow text-center">
|
||||
Authenticates against PocketBase through this server
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { isSuperadmin, request } from "../api";
|
||||
|
||||
// Listing is manager-scoped (an admin sees only their own org); creating,
|
||||
// renaming and deleting are superadmin-only, matching the server's gates.
|
||||
const orgs = ref([]);
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
const editing = ref(null); // org id, or "new"
|
||||
const draftName = ref("");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const out = await request("/api/orgs");
|
||||
orgs.value = out.organizations || [];
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
function startNew() {
|
||||
editing.value = "new";
|
||||
draftName.value = "";
|
||||
}
|
||||
|
||||
function startEdit(o) {
|
||||
editing.value = o.id;
|
||||
draftName.value = o.name;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing.value = null;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
async function save() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (editing.value === "new") {
|
||||
await request("/api/orgs", { method: "POST", body: { name: draftName.value } });
|
||||
} else {
|
||||
await request(`/api/orgs/${editing.value}`, {
|
||||
method: "PATCH",
|
||||
body: { name: draftName.value },
|
||||
});
|
||||
}
|
||||
editing.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(o) {
|
||||
if (!confirm(`Delete the organization "${o.name}"?`)) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await request(`/api/orgs/${o.id}`, { method: "DELETE" });
|
||||
await load();
|
||||
} catch (e) {
|
||||
// The server refuses (409) while the org still has members.
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dh-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div>
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">Organizations</div>
|
||||
<p class="mt-0.5 text-xs text-muted">Tenants users belong to</p>
|
||||
</div>
|
||||
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">New organization</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
|
||||
|
||||
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
|
||||
<label class="dh-label">Name</label>
|
||||
<input v-model="draftName" class="dh-input" placeholder="Acme Fleet" @keyup.enter="save" />
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button class="dh-btn" :disabled="busy || !draftName.trim()" @click="save">
|
||||
{{ editing === "new" ? "Create" : "Save" }}
|
||||
</button>
|
||||
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
|
||||
No organizations yet.
|
||||
</p>
|
||||
|
||||
<table v-else class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
|
||||
<th>Name</th>
|
||||
<th>ID</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orgs" :key="o.id" class="border-t border-subtle transition-colors hover:bg-sunken">
|
||||
<td class="px-5 py-2.5 font-medium text-strong">{{ o.name }}</td>
|
||||
<td class="data px-5 py-2.5 text-xs text-muted">{{ o.id }}</td>
|
||||
<td class="px-5 py-2.5 text-right whitespace-nowrap">
|
||||
<template v-if="isSuperadmin">
|
||||
<button class="dh-btn-ghost" @click="startEdit(o)">Rename</button>
|
||||
<button class="dh-btn-danger ml-1.5" :disabled="busy" @click="remove(o)">
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from "vue";
|
||||
import { request } from "../api";
|
||||
|
||||
// Superadmin-only. Each plugin advertises its own config fields (Descriptor.
|
||||
// ConfigFields), so the form below is generated rather than hard-coded — that's
|
||||
// what lets an external plugin be added without touching this panel.
|
||||
const plugins = ref([]);
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
const open = ref(null); // name of the expanded plugin
|
||||
const drafts = reactive({}); // name -> { key: value }
|
||||
const rowNotice = reactive({}); // name -> string
|
||||
const showRegister = ref(false);
|
||||
const reg = ref({ name: "", baseURL: "", provider: "" });
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const out = await request("/api/admin/plugins");
|
||||
plugins.value = out.plugins || [];
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
function expand(p) {
|
||||
if (open.value === p.name) {
|
||||
open.value = null;
|
||||
return;
|
||||
}
|
||||
// Seed the draft from the (secret-masked) stored config plus field defaults.
|
||||
const d = {};
|
||||
for (const f of p.configFields || []) d[f.key] = p.config?.[f.key] ?? "";
|
||||
drafts[p.name] = d;
|
||||
open.value = p.name;
|
||||
}
|
||||
|
||||
async function save(p, enabled) {
|
||||
busy.value = true;
|
||||
rowNotice[p.name] = "";
|
||||
try {
|
||||
const out = await request(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
|
||||
method: "PUT",
|
||||
body: { enabled, config: drafts[p.name] ?? {} },
|
||||
});
|
||||
// A save can succeed while Init fails (e.g. bad credentials) — the server
|
||||
// returns the saved plugin plus a warning.
|
||||
rowNotice[p.name] = out.warning || "Saved.";
|
||||
await load();
|
||||
} catch (e) {
|
||||
rowNotice[p.name] = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function health(p) {
|
||||
busy.value = true;
|
||||
rowNotice[p.name] = "Checking…";
|
||||
try {
|
||||
const out = await request(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
|
||||
method: "POST",
|
||||
});
|
||||
rowNotice[p.name] = `${out.health.status}${out.health.detail ? " — " + out.health.detail : ""}`;
|
||||
await load();
|
||||
} catch (e) {
|
||||
rowNotice[p.name] = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(p) {
|
||||
if (!confirm(`Remove the external plugin "${p.name}"? Its saved config is deleted.`)) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await request(`/api/admin/plugins/${encodeURIComponent(p.name)}`, { method: "DELETE" });
|
||||
if (open.value === p.name) open.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
rowNotice[p.name] = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerExternal() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await request("/api/admin/plugins", { method: "POST", body: reg.value });
|
||||
reg.value = { name: "", baseURL: "", provider: "" };
|
||||
showRegister.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const healthClass = (s) =>
|
||||
s === "ok"
|
||||
? "bg-success-soft text-success"
|
||||
: s === "degraded"
|
||||
? "bg-warning-soft text-warning"
|
||||
: "bg-danger-soft text-danger";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dh-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div>
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">Plugins</div>
|
||||
<p class="mt-0.5 text-xs text-muted">Third-party service integrations</p>
|
||||
</div>
|
||||
<button class="dh-btn-ghost" @click="showRegister = !showRegister">
|
||||
{{ showRegister ? "Cancel" : "Register external" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Register an external (remote HTTP) plugin — the no-rebuild path. -->
|
||||
<div v-if="showRegister" class="border-b border-subtle bg-sunken px-5 py-4">
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label class="dh-label">Name</label>
|
||||
<input v-model="reg.name" class="dh-input" placeholder="acme-parts" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Base URL</label>
|
||||
<input v-model="reg.baseURL" class="dh-input" placeholder="http://127.0.0.1:9100" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Provider</label>
|
||||
<input v-model="reg.provider" class="dh-input" placeholder="ACME Corp" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="dh-btn mt-3"
|
||||
:disabled="busy || !reg.name || !reg.baseURL"
|
||||
@click="registerExternal"
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
|
||||
|
||||
<p v-if="!plugins.length" class="px-5 py-6 text-center text-sm text-muted">
|
||||
No plugins yet. Register an external one above, or compile a built-in connector.
|
||||
</p>
|
||||
|
||||
<div v-for="p in plugins" :key="p.name" class="border-t border-subtle first:border-t-0">
|
||||
<!-- Summary row -->
|
||||
<div class="flex items-center gap-3 px-5 py-3">
|
||||
<button class="flex flex-1 items-center gap-3 text-left" @click="expand(p)">
|
||||
<span class="font-semibold text-strong">{{ p.name }}</span>
|
||||
<span class="dh-pill bg-sunken text-muted">{{ p.kind || "builtin" }}</span>
|
||||
<span v-if="p.provider" class="text-xs text-muted">{{ p.provider }}</span>
|
||||
<span v-if="p.health" class="dh-pill" :class="healthClass(p.health.status)">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ p.health.status }}
|
||||
</span>
|
||||
</button>
|
||||
<span class="dh-pill" :class="p.enabled ? 'bg-success-soft text-success' : 'bg-sunken text-muted'">
|
||||
{{ p.enabled ? "enabled" : "disabled" }}
|
||||
</span>
|
||||
<button class="dh-btn-ghost" :disabled="busy" @click="health(p)">Health</button>
|
||||
<button
|
||||
class="dh-btn-ghost"
|
||||
:disabled="busy"
|
||||
@click="expand(p)"
|
||||
>
|
||||
{{ open === p.name ? "Close" : "Configure" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expanded config: generated from the plugin's declared fields. -->
|
||||
<div v-if="open === p.name" class="bg-sunken px-5 py-4">
|
||||
<div v-if="p.baseURL" class="data mb-3 text-xs text-muted">{{ p.baseURL }}</div>
|
||||
|
||||
<div v-if="(p.configFields || []).length" class="grid gap-3 sm:grid-cols-2">
|
||||
<div v-for="f in p.configFields" :key="f.key">
|
||||
<label class="dh-label">
|
||||
{{ f.label || f.key }}<span v-if="f.required" class="text-danger"> *</span>
|
||||
</label>
|
||||
<select v-if="f.type === 'select'" v-model="drafts[p.name][f.key]" class="dh-select">
|
||||
<option v-for="o in f.options || []" :key="o.value" :value="o.value">
|
||||
{{ o.label || o.value }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-model="drafts[p.name][f.key]"
|
||||
class="dh-input"
|
||||
:type="f.type === 'password' ? 'password' : f.type === 'number' ? 'number' : 'text'"
|
||||
:placeholder="f.default || ''"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p v-if="f.help" class="mt-1 text-xs text-muted">{{ f.help }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-muted">This plugin takes no configuration.</p>
|
||||
|
||||
<div v-if="(p.capabilities || []).length" class="mt-4">
|
||||
<div class="eyebrow mb-1.5">Capabilities</div>
|
||||
<ul class="data flex flex-col gap-1 text-xs text-muted">
|
||||
<li v-for="c in p.capabilities" :key="c.id">
|
||||
<span class="text-strong">{{ c.id }}</span>
|
||||
<span v-if="c.method || c.endpoint"> — {{ c.method }} {{ c.endpoint }}</span>
|
||||
<span v-if="c.description"> · {{ c.description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p v-if="rowNotice[p.name]" class="data mt-3 text-xs text-body">{{ rowNotice[p.name] }}</p>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button class="dh-btn" :disabled="busy" @click="save(p, true)">
|
||||
{{ p.enabled ? "Save" : "Save & enable" }}
|
||||
</button>
|
||||
<button v-if="p.enabled" class="dh-btn-ghost" :disabled="busy" @click="save(p, false)">
|
||||
Disable
|
||||
</button>
|
||||
<span class="flex-1"></span>
|
||||
<button
|
||||
v-if="p.kind === 'external'"
|
||||
class="dh-btn-danger"
|
||||
:disabled="busy"
|
||||
@click="remove(p)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="p.kind !== 'external'" class="eyebrow mt-2">
|
||||
Built-in plugins can be disabled but not removed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { request } from "../api";
|
||||
|
||||
// Superadmin-only: retarget the PocketBase this server talks to. The change is
|
||||
// applied at runtime AND persisted to the server's .env, so it survives a
|
||||
// restart. A blank password means "keep the stored one".
|
||||
const cfg = ref(null);
|
||||
const form = ref({ url: "", adminEmail: "", adminPassword: "" });
|
||||
const probe = ref(null);
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const busy = ref(false);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
cfg.value = await request("/api/admin/pb-config");
|
||||
form.value = {
|
||||
url: cfg.value.url,
|
||||
adminEmail: cfg.value.adminEmail,
|
||||
adminPassword: "",
|
||||
};
|
||||
probe.value = cfg.value.probe;
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
async function test() {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
probe.value = await request("/api/admin/pb-config/test", {
|
||||
method: "POST",
|
||||
body: form.value,
|
||||
});
|
||||
notice.value = probe.value.superuser
|
||||
? "Connection OK — superuser authenticated."
|
||||
: probe.value.reachable
|
||||
? "PocketBase is reachable, but the service account did not authenticate."
|
||||
: "PocketBase is not reachable at that address.";
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
busy.value = true;
|
||||
try {
|
||||
const out = await request("/api/admin/pb-config", { method: "PUT", body: form.value });
|
||||
cfg.value = out.config;
|
||||
probe.value = out.config.probe;
|
||||
form.value.adminPassword = "";
|
||||
notice.value = out.warning || "Saved. The server is now using this PocketBase.";
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dh-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div>
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">PocketBase</div>
|
||||
<p class="mt-0.5 text-xs text-muted">Database connection used by every endpoint</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="probe"
|
||||
class="dh-pill"
|
||||
:class="probe.superuser
|
||||
? 'bg-success-soft text-success'
|
||||
: probe.reachable
|
||||
? 'bg-warning-soft text-warning'
|
||||
: 'bg-danger-soft text-danger'"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
||||
{{ probe.superuser ? "connected" : probe.reachable ? "no superuser" : "unreachable" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 px-5 py-4">
|
||||
<div>
|
||||
<label class="dh-label" for="pb-url">Base URL</label>
|
||||
<input id="pb-url" v-model="form.url" class="dh-input" placeholder="http://10.2.1.10:8027" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="dh-label" for="pb-email">Superuser email</label>
|
||||
<input id="pb-email" v-model="form.adminEmail" class="dh-input" autocomplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label" for="pb-password">Superuser password</label>
|
||||
<input
|
||||
id="pb-password"
|
||||
v-model="form.adminPassword"
|
||||
class="dh-input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="cfg?.adminConfigured ? 'unchanged' : 'not set'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="probe?.detail" class="data text-xs text-muted">{{ probe.detail }}</p>
|
||||
<p v-if="notice" class="rounded-control bg-info-soft px-3 py-2 text-xs text-info">{{ notice }}</p>
|
||||
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-xs text-danger">{{ error }}</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="dh-btn" :disabled="busy" @click="save">Save & apply</button>
|
||||
<button class="dh-btn-ghost" :disabled="busy" @click="test">Test connection</button>
|
||||
<span class="flex-1"></span>
|
||||
<span class="eyebrow">persisted to .env</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { request } from "../api";
|
||||
|
||||
// /api/status probes PocketBase and the Web App server-side, so the browser
|
||||
// never has to reach either directly.
|
||||
const status = ref(null);
|
||||
const error = ref("");
|
||||
let timer = null;
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
status.value = await request("/api/status", { auth: false });
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
status.value = null;
|
||||
error.value = e.message || "unreachable";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
check();
|
||||
timer = setInterval(check, 10000);
|
||||
});
|
||||
onUnmounted(() => clearInterval(timer));
|
||||
|
||||
const pillFor = (s) =>
|
||||
s === "ok" ? "bg-success-soft text-success" : "bg-danger-soft text-danger";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dh-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">Status</div>
|
||||
<span v-if="error" class="dh-pill bg-danger-soft text-danger">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>unreachable
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="px-5 py-4 text-sm text-danger">{{ error }}</div>
|
||||
|
||||
<table v-else-if="status" class="w-full text-left text-sm">
|
||||
<tbody>
|
||||
<tr v-for="row in [
|
||||
{ key: 'apiServer', label: 'API Server', h: status.apiServer },
|
||||
{ key: 'pocketBase', label: 'PocketBase', h: status.pocketBase },
|
||||
{ key: 'webApp', label: 'Web App', h: status.webApp },
|
||||
]" :key="row.key" class="border-t border-subtle first:border-t-0">
|
||||
<td class="px-5 py-3 font-medium text-strong">{{ row.label }}</td>
|
||||
<td class="data px-5 py-3 text-xs text-muted">{{ row.h.url || "this process" }}</td>
|
||||
<td class="data px-5 py-3 text-right text-xs text-muted">
|
||||
{{ row.h.latencyMs != null ? row.h.latencyMs + "ms" : "—" }}
|
||||
</td>
|
||||
<td class="px-5 py-3 text-right">
|
||||
<span class="dh-pill" :class="pillFor(row.h.status)">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ row.h.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-else class="px-5 py-4 text-sm text-muted">Checking…</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { request, me, isSuperadmin } from "../api";
|
||||
|
||||
// Manager-only. A superadmin sees and edits everyone; an admin is scoped by the
|
||||
// server to their own organization. The UI mirrors those limits, but the server
|
||||
// is what enforces them.
|
||||
const users = ref([]);
|
||||
const orgs = ref([]);
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
const editing = ref(null); // user id being edited, or "new"
|
||||
const draft = ref({});
|
||||
|
||||
const roles = computed(() =>
|
||||
isSuperadmin.value ? ["user", "admin", "superadmin"] : ["user", "admin"],
|
||||
);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [u, o] = await Promise.all([request("/api/users"), request("/api/orgs")]);
|
||||
users.value = u.users || [];
|
||||
orgs.value = o.organizations || [];
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
function startNew() {
|
||||
editing.value = "new";
|
||||
draft.value = {
|
||||
email: "",
|
||||
name: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
organization: isSuperadmin.value ? "" : me.value?.organization || "",
|
||||
};
|
||||
}
|
||||
|
||||
function startEdit(u) {
|
||||
editing.value = u.id;
|
||||
draft.value = {
|
||||
email: u.email,
|
||||
name: u.name || "",
|
||||
password: "",
|
||||
role: u.role,
|
||||
organization: u.organization || "",
|
||||
};
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing.value = null;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
async function save() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (editing.value === "new") {
|
||||
await request("/api/users", { method: "POST", body: draft.value });
|
||||
} else {
|
||||
// Only send a password when one was typed — blank means "leave it".
|
||||
const body = { ...draft.value };
|
||||
if (!body.password) delete body.password;
|
||||
await request(`/api/users/${editing.value}`, { method: "PATCH", body });
|
||||
}
|
||||
editing.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(u) {
|
||||
if (!confirm(`Delete ${u.email}? This cannot be undone.`)) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await request(`/api/users/${u.id}`, { method: "DELETE" });
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const roleClass = (r) =>
|
||||
r === "superadmin"
|
||||
? "bg-info-soft text-info"
|
||||
: r === "admin"
|
||||
? "bg-warning-soft text-warning"
|
||||
: "bg-sunken text-muted";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dh-card overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div>
|
||||
<div class="text-base font-bold tracking-[-0.02em] text-strong">Users</div>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ isSuperadmin ? "All organizations" : "Your organization" }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="dh-btn" @click="startNew">New user</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
|
||||
|
||||
<!-- Create / edit form -->
|
||||
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="dh-label">Email</label>
|
||||
<input v-model="draft.email" class="dh-input" type="email" autocomplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Name</label>
|
||||
<input v-model="draft.name" class="dh-input" autocomplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">
|
||||
Password{{ editing === "new" ? "" : " (blank = unchanged)" }}
|
||||
</label>
|
||||
<input
|
||||
v-model="draft.password"
|
||||
class="dh-input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="min 8 characters"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="dh-label">Role</label>
|
||||
<select v-model="draft.role" class="dh-select">
|
||||
<option v-for="r in roles" :key="r" :value="r">{{ r }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="isSuperadmin">
|
||||
<label class="dh-label">Organization</label>
|
||||
<select v-model="draft.organization" class="dh-select">
|
||||
<option value="">— none —</option>
|
||||
<option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button class="dh-btn" :disabled="busy" @click="save">
|
||||
{{ editing === "new" ? "Create" : "Save" }}
|
||||
</button>
|
||||
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!users.length" class="px-5 py-6 text-center text-sm text-muted">No users.</p>
|
||||
|
||||
<table v-else class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Organization</th>
|
||||
<th>Role</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id" class="border-t border-subtle transition-colors hover:bg-sunken">
|
||||
<td class="data px-5 py-2.5 text-xs text-strong">
|
||||
{{ u.email }}
|
||||
<span v-if="u.id === me?.id" class="eyebrow ml-1">you</span>
|
||||
</td>
|
||||
<td class="px-5 py-2.5 text-body">{{ u.name || "—" }}</td>
|
||||
<td class="px-5 py-2.5 text-body">{{ u.organizationName || "—" }}</td>
|
||||
<td class="px-5 py-2.5">
|
||||
<span class="dh-pill" :class="roleClass(u.role)">{{ u.role }}</span>
|
||||
</td>
|
||||
<td class="px-5 py-2.5 text-right whitespace-nowrap">
|
||||
<button class="dh-btn-ghost" @click="startEdit(u)">Edit</button>
|
||||
<button
|
||||
v-if="u.id !== me?.id"
|
||||
class="dh-btn-danger ml-1.5"
|
||||
:disabled="busy"
|
||||
@click="remove(u)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -211,6 +211,107 @@ body {
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
/* Primary button (save, sign in, create). */
|
||||
.dh-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-control);
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease;
|
||||
}
|
||||
.dh-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.dh-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.dh-btn:disabled,
|
||||
.dh-btn-ghost:disabled,
|
||||
.dh-btn-danger:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Destructive button (delete a user, an org, an external plugin). */
|
||||
.dh-btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-control);
|
||||
border: 1px solid var(--border-default);
|
||||
background: transparent;
|
||||
color: var(--danger-600);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.dh-btn-danger:hover:not(:disabled) {
|
||||
background: var(--danger-100);
|
||||
border-color: var(--danger-600);
|
||||
}
|
||||
|
||||
/* Form controls. */
|
||||
.dh-input,
|
||||
.dh-select {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-control);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.8125rem;
|
||||
transition: border-color 140ms ease;
|
||||
}
|
||||
.dh-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.dh-input:focus,
|
||||
.dh-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
.dh-label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Status pill (health badges, roles, plugin kinds). */
|
||||
.dh-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dh-card {
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: var(--surface-card);
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
// Set a user's access role ("user" or "admin") by email.
|
||||
// Set a user's access role ("user", "admin" or "superadmin") by email.
|
||||
//
|
||||
// This is the bootstrap path for the first superadmin: the panel's management
|
||||
// screens require one, and only a superadmin can promote another, so the very
|
||||
// first one has to be minted here.
|
||||
//
|
||||
// Usage (PowerShell):
|
||||
// $env:PB_URL="http://10.2.1.10:8027"
|
||||
// $env:PB_ADMIN_EMAIL="admin@carcontrole.local"
|
||||
// $env:PB_ADMIN_PASSWORD="..."
|
||||
// node scripts/set-role.mjs <email> <user|admin>
|
||||
// $env:POCKETBASE_URL="http://10.2.1.10:8027"
|
||||
// $env:POCKETBASE_ADMIN_EMAIL="admin@drivervault.local"
|
||||
// $env:POCKETBASE_ADMIN_PASSWORD="..."
|
||||
// node scripts/set-role.mjs <email> <user|admin|superadmin>
|
||||
|
||||
const PB_URL = (process.env.PB_URL || "http://10.2.1.10:8027").replace(/\/+$/, "");
|
||||
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
|
||||
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
|
||||
const ROLES = ["user", "admin", "superadmin"];
|
||||
|
||||
const PB_URL = (process.env.POCKETBASE_URL || process.env.PB_URL || "http://10.2.1.10:8027").replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
const ADMIN_EMAIL = process.env.POCKETBASE_ADMIN_EMAIL || process.env.PB_ADMIN_EMAIL;
|
||||
const ADMIN_PASSWORD = process.env.POCKETBASE_ADMIN_PASSWORD || process.env.PB_ADMIN_PASSWORD;
|
||||
|
||||
const [, , email, role] = process.argv;
|
||||
|
||||
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
||||
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.");
|
||||
console.error("Set POCKETBASE_ADMIN_EMAIL and POCKETBASE_ADMIN_PASSWORD.");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!email || !role || !["user", "admin"].includes(role)) {
|
||||
console.error("Usage: node scripts/set-role.mjs <email> <user|admin>");
|
||||
if (!email || !role || !ROLES.includes(role)) {
|
||||
console.error(`Usage: node scripts/set-role.mjs <email> <${ROLES.join("|")}>`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ async function createCollection(token, name, defs, format, idByName) {
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
};
|
||||
if (INDEXES[name]) body.indexes = INDEXES[name];
|
||||
const res = await fetch(PB_URL + "/api/collections", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: token },
|
||||
@@ -247,6 +248,12 @@ const DESIRED = {
|
||||
F.select("permission", ["read", "write"], true),
|
||||
F.autodate("created", true, false),
|
||||
],
|
||||
// Tenants that users belong to. A superadmin spans all of them; an admin
|
||||
// manages only their own.
|
||||
organizations: [
|
||||
F.text("name", true),
|
||||
F.autodate("created", true, false),
|
||||
],
|
||||
// Custom fields layered onto the built-in "users" auth collection (which
|
||||
// already ships with email/name/avatar). Settings-panel additions:
|
||||
users: [
|
||||
@@ -257,22 +264,20 @@ const DESIRED = {
|
||||
F.select("font_size", ["small", "medium", "large"]),
|
||||
F.date("deletion_requested_at"),
|
||||
// Access role. Empty value is treated as "user" by the API.
|
||||
F.select("role", ["user", "admin"]),
|
||||
],
|
||||
// One row per issued login token, so "active sessions" can be listed and
|
||||
// individually revoked without needing a stateful session store elsewhere.
|
||||
sessions: [
|
||||
F.relation("user", "users", true),
|
||||
F.text("jti", true),
|
||||
F.text("device_label"),
|
||||
F.text("ip"),
|
||||
F.text("user_agent"),
|
||||
F.bool("revoked"),
|
||||
F.date("expires_at", true),
|
||||
F.autodate("created", true, false),
|
||||
F.select("role", ["user", "admin", "superadmin"]),
|
||||
// Organization membership. Non-cascading on purpose: deleting an org must
|
||||
// not delete its people. (The API refuses to delete an org that still has
|
||||
// members, so this should not arise in practice.)
|
||||
F.relation("organization", "organizations", false, false),
|
||||
],
|
||||
};
|
||||
|
||||
// Extra SQL indexes, applied at collection-create time. Organization names are
|
||||
// unique so the API can rely on PocketBase rejecting a duplicate.
|
||||
const INDEXES = {
|
||||
organizations: ["CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
console.log(`Connecting to ${PB_URL} ...`);
|
||||
const token = await authenticate();
|
||||
@@ -285,10 +290,10 @@ async function main() {
|
||||
const idByName = {};
|
||||
for (const c of collections) idByName[c.name] = c.id;
|
||||
|
||||
// Create in dependency order (cars before its relations; "users" already
|
||||
// exists as PocketBase's built-in auth collection, so it's never created
|
||||
// here — only reconciled below).
|
||||
for (const name of ["cars", "service_records", "parts", "sessions", "car_shares"]) {
|
||||
// Create in dependency order (organizations before users references it; cars
|
||||
// before its relations; "users" already exists as PocketBase's built-in auth
|
||||
// collection, so it's never created here — only reconciled below).
|
||||
for (const name of ["organizations", "cars", "service_records", "parts", "car_shares"]) {
|
||||
if (collections.some((c) => c.name === name)) continue;
|
||||
await createCollection(token, name, DESIRED[name], format, idByName);
|
||||
console.log(`✓ ${name} — created`);
|
||||
@@ -297,12 +302,20 @@ async function main() {
|
||||
for (const c of collections) idByName[c.name] = c.id;
|
||||
}
|
||||
|
||||
// Reconcile fields on existing collections (add missing + fix relation options).
|
||||
for (const name of ["users", "cars", "service_records", "parts", "sessions", "car_shares"]) {
|
||||
// Reconcile fields on existing collections (add missing + fix relation options
|
||||
// and select values — this is what grows users.role to include "superadmin"
|
||||
// and adds users.organization on an existing deployment).
|
||||
for (const name of ["organizations", "users", "cars", "service_records", "parts", "car_shares"]) {
|
||||
await reconcileFields(token, name, DESIRED[name], format, idByName);
|
||||
}
|
||||
|
||||
console.log("\nDone. Collections ready: users, cars, service_records, parts, sessions, car_shares.");
|
||||
console.log(
|
||||
"\nDone. Collections ready: organizations, users, cars, service_records, parts, car_shares.",
|
||||
);
|
||||
console.log(
|
||||
"Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
|
||||
"tokens). It is left in place rather than dropped — delete it by hand if you want.",
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
+39
-21
@@ -75,16 +75,39 @@ class ApiClient {
|
||||
|
||||
final data = jsonDecode(res.body);
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
final msg = data is Map && data["error"] != null ? data["error"].toString() : res.reasonPhrase;
|
||||
throw ApiException(res.statusCode, msg ?? "Request failed");
|
||||
throw ApiException(res.statusCode, _errorMessage(data, res.reasonPhrase));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// Digs a human-readable message out of the error shapes in play: this
|
||||
/// server's {error}, and PocketBase's {message, data:{field:{message}}} —
|
||||
/// which the user endpoints relay verbatim, so a duplicate email arrives as a
|
||||
/// per-field error rather than a flat string.
|
||||
String _errorMessage(dynamic data, String? fallback) {
|
||||
if (data is! Map) return fallback ?? "Request failed";
|
||||
if (data["error"] != null) return data["error"].toString();
|
||||
|
||||
final fields = data["data"];
|
||||
if (fields is Map && fields.isNotEmpty) {
|
||||
final parts = fields.entries.map((e) {
|
||||
final v = e.value;
|
||||
final msg = v is Map && v["message"] != null ? v["message"] : v;
|
||||
return "${e.key}: $msg";
|
||||
});
|
||||
return parts.join("; ");
|
||||
}
|
||||
if (data["message"] != null) return data["message"].toString();
|
||||
return fallback ?? "Request failed";
|
||||
}
|
||||
|
||||
// --- auth ---
|
||||
/// The API Server proxies login to PocketBase and relays its response
|
||||
/// verbatim, so the user arrives under `record` (PocketBase's name) and the
|
||||
/// token is PocketBase's own — the server no longer mints its own JWT.
|
||||
Future<(String, AuthUser)> login(String email, String password) async {
|
||||
final data = await _send("POST", "/auth/login", body: {"email": email, "password": password});
|
||||
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["user"])));
|
||||
return (data["token"] as String, AuthUser.fromJson(Map<String, dynamic>.from(data["record"])));
|
||||
}
|
||||
|
||||
// --- cars ---
|
||||
@@ -125,31 +148,35 @@ class ApiClient {
|
||||
Future<void> removeCarShare(String carId, String userId) =>
|
||||
_send("DELETE", "/cars/$carId/shares/$userId");
|
||||
|
||||
// --- admin: user management (admin role only) ---
|
||||
// --- user management (admin or superadmin) ---
|
||||
// Admins are scoped by the server to their own organization; superadmins see
|
||||
// everyone. Responses are enveloped ({users}/{user}).
|
||||
Future<List<AdminUser>> listUsers() async {
|
||||
final data = await _send("GET", "/admin/users") as List;
|
||||
return data.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
final data = await _send("GET", "/users");
|
||||
final items = (data["users"] ?? []) as List;
|
||||
return items.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
Future<AdminUser> createUser(
|
||||
{required String email, required String password, String? name, String role = "user"}) async {
|
||||
final data = await _send("POST", "/admin/users",
|
||||
final data = await _send("POST", "/users",
|
||||
body: {"email": email, "password": password, "name": name ?? "", "role": role});
|
||||
return AdminUser.fromJson(Map<String, dynamic>.from(data));
|
||||
return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
|
||||
}
|
||||
|
||||
Future<AdminUser> updateUser(String id, {String? name, String? role}) async {
|
||||
final body = <String, dynamic>{};
|
||||
if (name != null) body["name"] = name;
|
||||
if (role != null) body["role"] = role;
|
||||
final data = await _send("PATCH", "/admin/users/$id", body: body);
|
||||
return AdminUser.fromJson(Map<String, dynamic>.from(data));
|
||||
final data = await _send("PATCH", "/users/$id", body: body);
|
||||
return AdminUser.fromJson(Map<String, dynamic>.from(data["user"]));
|
||||
}
|
||||
|
||||
/// Password resets are a field on the user PATCH now, not a separate endpoint.
|
||||
Future<void> setUserPassword(String id, String newPassword) =>
|
||||
_send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword});
|
||||
_send("PATCH", "/users/$id", body: {"password": newPassword});
|
||||
|
||||
Future<void> deleteUser(String id) => _send("DELETE", "/admin/users/$id");
|
||||
Future<void> deleteUser(String id) => _send("DELETE", "/users/$id");
|
||||
|
||||
// --- service records ---
|
||||
Future<List<ServiceRecord>> listCarServices(String carId) async {
|
||||
@@ -233,15 +260,6 @@ class ApiClient {
|
||||
|
||||
Future<void> deleteAvatar() => _send("DELETE", "/me/avatar");
|
||||
|
||||
// --- settings: sessions ---
|
||||
Future<List<Session>> listSessions() async {
|
||||
final data = await _send("GET", "/sessions") as List;
|
||||
return data.map((e) => Session.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
Future<void> revokeSession(String id) => _send("DELETE", "/sessions/$id");
|
||||
Future<void> revokeOtherSessions() => _send("DELETE", "/sessions");
|
||||
|
||||
// --- settings: account deletion ---
|
||||
Future<DateTime?> requestAccountDeletion(String confirmEmail) async {
|
||||
final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail});
|
||||
|
||||
+19
-32
@@ -176,21 +176,27 @@ class CarShare {
|
||||
String get label => name.isNotEmpty ? name : email;
|
||||
}
|
||||
|
||||
/// Roles allowed to manage users. A superadmin is an admin that also spans
|
||||
/// every organization; the API Server enforces that difference.
|
||||
const _managerRoles = {"admin", "superadmin"};
|
||||
|
||||
class AuthUser {
|
||||
final String id;
|
||||
final String email;
|
||||
final String name;
|
||||
final String role; // "user" | "admin"
|
||||
final String role; // "user" | "admin" | "superadmin"
|
||||
AuthUser({required this.id, required this.email, required this.name, this.role = "user"});
|
||||
|
||||
factory AuthUser.fromJson(Map<String, dynamic> j) => AuthUser(
|
||||
id: _asStr(j["id"]),
|
||||
email: _asStr(j["email"]),
|
||||
name: _asStr(j["name"]),
|
||||
role: j["role"] == null ? "user" : _asStr(j["role"]),
|
||||
// An empty role is treated as "user", matching the server.
|
||||
role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
|
||||
);
|
||||
|
||||
bool get isAdmin => role == "admin";
|
||||
bool get isAdmin => _managerRoles.contains(role);
|
||||
bool get isSuperadmin => role == "superadmin";
|
||||
|
||||
Map<String, dynamic> toJson() => {"id": id, "email": email, "name": name, "role": role};
|
||||
}
|
||||
@@ -202,6 +208,7 @@ class AdminUser {
|
||||
final String name;
|
||||
final String role;
|
||||
final String created;
|
||||
final String organizationName; // "" when the user belongs to no organization
|
||||
|
||||
AdminUser({
|
||||
required this.id,
|
||||
@@ -209,15 +216,19 @@ class AdminUser {
|
||||
required this.name,
|
||||
required this.role,
|
||||
required this.created,
|
||||
this.organizationName = "",
|
||||
});
|
||||
|
||||
factory AdminUser.fromJson(Map<String, dynamic> j) => AdminUser(
|
||||
id: _asStr(j["id"]),
|
||||
email: _asStr(j["email"]),
|
||||
name: _asStr(j["name"]),
|
||||
role: _asStr(j["role"]),
|
||||
role: _asStr(j["role"]).isEmpty ? "user" : _asStr(j["role"]),
|
||||
created: _asStr(j["created"]),
|
||||
organizationName: _asStr(j["organizationName"]),
|
||||
);
|
||||
|
||||
bool get isSuperadmin => role == "superadmin";
|
||||
}
|
||||
|
||||
/// The full authenticated profile (Settings panel), mirroring /api/me.
|
||||
@@ -267,34 +278,10 @@ class UserProfile {
|
||||
: DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(),
|
||||
);
|
||||
|
||||
bool get isAdmin => role == "admin";
|
||||
bool get isAdmin => _managerRoles.contains(role);
|
||||
bool get isSuperadmin => role == "superadmin";
|
||||
bool get deletionPending => deletionRequestedAt != null;
|
||||
}
|
||||
|
||||
/// One active login session (device), mirroring /api/sessions.
|
||||
class Session {
|
||||
final String id;
|
||||
final String deviceLabel;
|
||||
final String ip;
|
||||
final bool current;
|
||||
final DateTime? created;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
Session({
|
||||
required this.id,
|
||||
required this.deviceLabel,
|
||||
required this.ip,
|
||||
required this.current,
|
||||
required this.created,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
factory Session.fromJson(Map<String, dynamic> j) => Session(
|
||||
id: _asStr(j["id"]),
|
||||
deviceLabel: _asStr(j["deviceLabel"]),
|
||||
ip: _asStr(j["ip"]),
|
||||
current: _asBool(j["current"]),
|
||||
created: DateTime.tryParse(_asStr(j["created"]))?.toLocal(),
|
||||
expiresAt: DateTime.tryParse(_asStr(j["expiresAt"]))?.toLocal(),
|
||||
);
|
||||
}
|
||||
// The Session model is gone: auth now uses PocketBase's own stateless tokens,
|
||||
// so there is no per-device session list to show or revoke.
|
||||
|
||||
@@ -6,8 +6,10 @@ import "../format.dart";
|
||||
import "../theme.dart";
|
||||
|
||||
/// Admin-only screen to manage user accounts: list, create, change role,
|
||||
/// reset password, delete. The API enforces the real access control and the
|
||||
/// last-admin / self-delete guards; the UI mirrors them to avoid dead actions.
|
||||
/// reset password, delete. The API enforces the real access control — an admin
|
||||
/// is scoped to their own organization and cannot touch a superadmin, and
|
||||
/// nobody may change their own role or delete their own account. The UI mirrors
|
||||
/// those guards to avoid offering dead actions.
|
||||
class AdminUsersScreen extends StatefulWidget {
|
||||
const AdminUsersScreen({super.key});
|
||||
@override
|
||||
@@ -26,6 +28,11 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
void _reload() => setState(() => _future = apiClient.listUsers());
|
||||
|
||||
String? get _myId => authService.user?.id;
|
||||
bool get _iamSuperadmin => authService.user?.isSuperadmin == true;
|
||||
|
||||
/// Roles this viewer may hand out. Only a superadmin can mint another one.
|
||||
List<String> get _assignableRoles =>
|
||||
_iamSuperadmin ? const ["user", "admin", "superadmin"] : const ["user", "admin"];
|
||||
|
||||
void _snack(String msg) =>
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
@@ -121,7 +128,6 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
return Center(child: Text("${snap.error}"));
|
||||
}
|
||||
final users = snap.data ?? [];
|
||||
final adminCount = users.where((u) => u.role == "admin").length;
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: users.length,
|
||||
@@ -129,7 +135,8 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
itemBuilder: (context, i) {
|
||||
final u = users[i];
|
||||
final isSelf = u.id == _myId;
|
||||
final lastAdmin = u.role == "admin" && adminCount <= 1;
|
||||
// An admin may not edit or delete a superadmin; only a superadmin may.
|
||||
final locked = u.isSuperadmin && !_iamSuperadmin;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
title: Row(
|
||||
@@ -143,20 +150,28 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
"${u.name.isEmpty ? '—' : u.name} · ${formatDate(DateTime.tryParse(u.created))}",
|
||||
"${u.name.isEmpty ? '—' : u.name}"
|
||||
"${u.organizationName.isEmpty ? '' : ' · ${u.organizationName}'}"
|
||||
" · ${formatDate(DateTime.tryParse(u.created))}",
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
DropdownButton<String>(
|
||||
value: u.role == "admin" ? "admin" : "user",
|
||||
value: u.role,
|
||||
underline: const SizedBox.shrink(),
|
||||
// Disable demoting the last admin.
|
||||
onChanged: lastAdmin ? null : (v) => v == null ? null : _changeRole(u, v),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "user", child: Text("user")),
|
||||
DropdownMenuItem(value: "admin", child: Text("admin")),
|
||||
// Nobody may change their own role; only a superadmin may
|
||||
// touch a superadmin.
|
||||
onChanged:
|
||||
(isSelf || locked) ? null : (v) => v == null ? null : _changeRole(u, v),
|
||||
items: [
|
||||
for (final r in _assignableRoles)
|
||||
DropdownMenuItem(value: r, child: Text(r)),
|
||||
// Keep the current role selectable even when this viewer
|
||||
// can't assign it, so the dropdown has a valid value.
|
||||
if (!_assignableRoles.contains(u.role))
|
||||
DropdownMenuItem(value: u.role, child: Text(u.role)),
|
||||
],
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
@@ -165,11 +180,15 @@ class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||
if (choice == "delete") _delete(u);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: "password", child: Text("Reset password")),
|
||||
PopupMenuItem(
|
||||
value: "password",
|
||||
enabled: !locked,
|
||||
child: const Text("Reset password"),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: "delete",
|
||||
// Can't delete yourself or the last admin.
|
||||
enabled: !isSelf && !lastAdmin,
|
||||
// Can't delete yourself, or a superadmin you don't outrank.
|
||||
enabled: !isSelf && !locked,
|
||||
child: const Text("Delete", style: TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
],
|
||||
@@ -199,6 +218,12 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
/// Only a superadmin can create another one. An admin's new users are placed
|
||||
/// in the admin's own organization by the server.
|
||||
List<String> get _assignableRoles => authService.user?.isSuperadmin == true
|
||||
? const ["user", "admin", "superadmin"]
|
||||
: const ["user", "admin"];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_email.dispose();
|
||||
@@ -274,9 +299,8 @@ class _CreateUserSheetState extends State<_CreateUserSheet> {
|
||||
DropdownButton<String>(
|
||||
value: _role,
|
||||
onChanged: (v) => setState(() => _role = v ?? "user"),
|
||||
items: const [
|
||||
DropdownMenuItem(value: "user", child: Text("user")),
|
||||
DropdownMenuItem(value: "admin", child: Text("admin")),
|
||||
items: [
|
||||
for (final r in _assignableRoles) DropdownMenuItem(value: r, child: Text(r)),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -20,7 +20,6 @@ class SettingsScreen extends StatefulWidget {
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
UserProfile? _profile;
|
||||
List<Session> _sessions = [];
|
||||
Uint8List? _avatar;
|
||||
bool _loading = true;
|
||||
String? _loadError;
|
||||
@@ -49,7 +48,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
try {
|
||||
final p = await apiClient.getMe();
|
||||
appSettings.applyFromProfile(p);
|
||||
final sessions = await apiClient.listSessions();
|
||||
Uint8List? avatar;
|
||||
if (p.hasAvatar) {
|
||||
final bytes = await apiClient.getAvatarBytes();
|
||||
@@ -59,7 +57,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_bio.text = p.bio;
|
||||
setState(() {
|
||||
_profile = p;
|
||||
_sessions = sessions;
|
||||
_avatar = avatar;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -102,7 +99,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
const SizedBox(height: 12),
|
||||
_SecuritySection(email: _profile!.email, snack: _snack),
|
||||
const SizedBox(height: 12),
|
||||
_SessionsSection(sessions: _sessions, onChanged: _load, snack: _snack),
|
||||
const _PrivacySection(),
|
||||
const SizedBox(height: 12),
|
||||
_DangerSection(profile: _profile!, onChanged: _load, snack: _snack),
|
||||
const SizedBox(height: 24),
|
||||
@@ -669,39 +666,12 @@ class _SecuritySectionState extends State<_SecuritySection> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Privacy & security (sessions) -----------------------------------------
|
||||
// --- Privacy & security -----------------------------------------------------
|
||||
|
||||
class _SessionsSection extends StatefulWidget {
|
||||
final List<Session> sessions;
|
||||
final Future<void> Function() onChanged;
|
||||
final void Function(String) snack;
|
||||
const _SessionsSection({required this.sessions, required this.onChanged, required this.snack});
|
||||
@override
|
||||
State<_SessionsSection> createState() => _SessionsSectionState();
|
||||
}
|
||||
|
||||
class _SessionsSectionState extends State<_SessionsSection> {
|
||||
Future<void> _revoke(Session s) async {
|
||||
try {
|
||||
await apiClient.revokeSession(s.id);
|
||||
if (s.current) {
|
||||
await authService.logout();
|
||||
return;
|
||||
}
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _revokeOthers() async {
|
||||
try {
|
||||
await apiClient.revokeOtherSessions();
|
||||
await widget.onChanged();
|
||||
} catch (e) {
|
||||
widget.snack("$e");
|
||||
}
|
||||
}
|
||||
/// Sessions are PocketBase's own stateless tokens, so there is no per-device
|
||||
/// list to show or revoke — this card explains what "sign out" actually does.
|
||||
class _PrivacySection extends StatelessWidget {
|
||||
const _PrivacySection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -709,53 +679,19 @@ class _SessionsSectionState extends State<_SessionsSection> {
|
||||
title: "Privacy & security",
|
||||
children: [
|
||||
const Text(
|
||||
"Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.",
|
||||
"Two-factor authentication isn't available yet. Sessions are held as "
|
||||
"server-issued tokens that expire on their own, so signing out ends "
|
||||
"this device's session only. To lock out every device, change your "
|
||||
"password above.",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||
),
|
||||
if (widget.sessions.length > 1)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _revokeOthers,
|
||||
child: const Text("Log out all other devices"),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => authService.logout(),
|
||||
child: const Text("Sign out", style: TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
...widget.sessions.map((s) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Flexible(
|
||||
child: Text(s.deviceLabel,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
if (s.current)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(left: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: DriverVault.brandTint(context),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text("This device",
|
||||
style: TextStyle(fontSize: 11, color: DriverVault.brandOnTint(context))),
|
||||
),
|
||||
]),
|
||||
Text("${s.ip} · signed in ${formatDate(s.created)}",
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _revoke(s),
|
||||
child: const Text("Log out", style: TextStyle(color: DriverVault.danger)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
+26
-16
@@ -45,13 +45,24 @@ async function handleResponse(res, path) {
|
||||
if (res.status === 204) return null;
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const msg = (data && (data.error || data.message)) || res.statusText;
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (!res.ok) throw new Error(errorMessage(data, res.statusText));
|
||||
return data;
|
||||
}
|
||||
|
||||
// Digs a human-readable message out of the error shapes in play: this server's
|
||||
// {error}, and PocketBase's {message, data:{field:{message}}} — which the user
|
||||
// and organization endpoints relay verbatim, so a duplicate email arrives as a
|
||||
// per-field error rather than a flat string.
|
||||
function errorMessage(data, fallback) {
|
||||
if (!data || typeof data !== "object") return fallback;
|
||||
if (data.error) return data.error;
|
||||
const fieldErrors = Object.entries(data.data || {})
|
||||
.map(([field, e]) => `${field}: ${e?.message || e}`)
|
||||
.filter(Boolean);
|
||||
if (fieldErrors.length) return fieldErrors.join("; ");
|
||||
return data.message || fallback;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(apiBase() + path, {
|
||||
headers: { "Content-Type": "application/json", ...authHeader(), ...(options.headers || {}) },
|
||||
@@ -112,13 +123,17 @@ export const api = {
|
||||
request(`/parts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deletePart: (id) => request(`/parts/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Admin — user management (admin role only)
|
||||
listUsers: () => request("/admin/users"),
|
||||
createUser: (body) => request("/admin/users", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateUser: (id, body) => request(`/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
setUserPassword: (id, newPassword) =>
|
||||
request(`/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ newPassword }) }),
|
||||
deleteUser: (id) => request(`/admin/users/${id}`, { method: "DELETE" }),
|
||||
// Admin — user management (admin or superadmin). Admins are scoped by the
|
||||
// server to their own organization; superadmins see everyone.
|
||||
listUsers: () => request("/users").then((r) => r.users),
|
||||
createUser: (body) =>
|
||||
request("/users", { method: "POST", body: JSON.stringify(body) }).then((r) => r.user),
|
||||
updateUser: (id, body) =>
|
||||
request(`/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }).then((r) => r.user),
|
||||
// Password resets are a field on the user PATCH now, not a separate endpoint.
|
||||
setUserPassword: (id, password) =>
|
||||
request(`/users/${id}`, { method: "PATCH", body: JSON.stringify({ password }) }).then((r) => r.user),
|
||||
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Settings — account/profile/appearance
|
||||
getMe: () => request("/me"),
|
||||
@@ -134,11 +149,6 @@ export const api = {
|
||||
deleteAvatar: () => request("/me/avatar", { method: "DELETE" }),
|
||||
getAvatarBlob: () => requestBlob("/me/avatar"),
|
||||
|
||||
// Settings — privacy & security (active sessions)
|
||||
listSessions: () => request("/sessions"),
|
||||
revokeSession: (id) => request(`/sessions/${id}`, { method: "DELETE" }),
|
||||
revokeOtherSessions: () => request("/sessions", { method: "DELETE" }),
|
||||
|
||||
// Settings — advanced / danger zone
|
||||
exportData: () => requestBlob("/me/export"),
|
||||
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),
|
||||
|
||||
+11
-3
@@ -15,19 +15,27 @@ export const state = reactive({
|
||||
|
||||
export const isAuthenticated = computed(() => !!state.token);
|
||||
|
||||
// Roles that may manage users. A superadmin is an admin that also spans every
|
||||
// organization; the API Server enforces the difference, the UI just needs to
|
||||
// know whether to offer the Users screen at all.
|
||||
const MANAGER_ROLES = ["admin", "superadmin"];
|
||||
|
||||
// Admin gate for the UI. Driven by the full profile (fetched from /api/me),
|
||||
// which always reflects the current role from the DB — so a promotion/demotion
|
||||
// takes effect on the next profile refresh without needing a re-login.
|
||||
export const isAdmin = computed(
|
||||
() => state.profile?.role === "admin" || state.user?.role === "admin"
|
||||
() => MANAGER_ROLES.includes(state.profile?.role) || MANAGER_ROLES.includes(state.user?.role)
|
||||
);
|
||||
|
||||
export async function login(email, password) {
|
||||
// The API Server proxies login to PocketBase and relays its response
|
||||
// verbatim, so the user is under `record` (PocketBase's name) and the token
|
||||
// is PocketBase's own — this app no longer holds a server-minted JWT.
|
||||
const res = await api.login(email, password);
|
||||
state.token = res.token;
|
||||
state.user = res.user;
|
||||
state.user = res.record;
|
||||
localStorage.setItem(TOKEN_KEY, res.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(res.record));
|
||||
await refreshProfile();
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { api } from "../api";
|
||||
import { state } from "../auth";
|
||||
import { formatDate } from "../lib/format.js";
|
||||
@@ -22,17 +22,26 @@ const savingPw = ref(false);
|
||||
const pwError = ref("");
|
||||
|
||||
const myId = state.user?.id;
|
||||
const adminCount = () => users.value.filter((u) => u.role === "admin").length;
|
||||
const myRole = computed(() => state.profile?.role || state.user?.role || "user");
|
||||
const isSuperadmin = computed(() => myRole.value === "superadmin");
|
||||
|
||||
// Whether the destructive/demote controls should be disabled for a row, with a
|
||||
// reason (mirrors the server guards so the UI doesn't offer a doomed action).
|
||||
// Roles this viewer may hand out. Only a superadmin can mint another one; the
|
||||
// server rejects it either way, this just doesn't offer a doomed option.
|
||||
const assignableRoles = computed(() =>
|
||||
isSuperadmin.value ? ["user", "admin", "superadmin"] : ["user", "admin"],
|
||||
);
|
||||
|
||||
// Whether the destructive/role controls should be disabled for a row, with a
|
||||
// reason. These mirror the server's guards so the UI doesn't offer an action
|
||||
// that is going to come back as a 400/403.
|
||||
function deleteBlockedReason(u) {
|
||||
if (u.id === myId) return "You can't delete your own account.";
|
||||
if (u.role === "admin" && adminCount() <= 1) return "Can't delete the last admin.";
|
||||
if (u.role === "superadmin" && !isSuperadmin.value) return "Only a superadmin can delete a superadmin.";
|
||||
return "";
|
||||
}
|
||||
function roleLockReason(u) {
|
||||
if (u.role === "admin" && adminCount() <= 1) return "Can't demote the last admin.";
|
||||
if (u.id === myId) return "You can't change your own role.";
|
||||
if (u.role === "superadmin" && !isSuperadmin.value) return "Only a superadmin can edit a superadmin.";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -119,7 +128,10 @@ onMounted(load);
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Users</h1>
|
||||
<p class="mt-1 text-sm text-muted">Manage accounts and roles.</p>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{{ isSuperadmin ? "Accounts across every organization." : "Accounts in your organization." }}
|
||||
Organizations are assigned in the API panel.
|
||||
</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-primary" @click="showCreate = true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
|
||||
@@ -136,6 +148,7 @@ onMounted(load);
|
||||
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Organization</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
@@ -148,6 +161,7 @@ onMounted(load);
|
||||
<span v-if="u.id === myId" class="ml-1 text-xs text-muted">(you)</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-body">{{ u.name || '—' }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ u.organizationName || '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<select
|
||||
:value="u.role"
|
||||
@@ -156,8 +170,9 @@ onMounted(load);
|
||||
class="dh-input w-auto !py-1 !text-xs disabled:opacity-60"
|
||||
@change="changeRole(u, $event.target.value)"
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
<option v-for="r in assignableRoles" :key="r" :value="r">{{ r }}</option>
|
||||
<!-- Keep the current role selectable even when this viewer can't assign it. -->
|
||||
<option v-if="!assignableRoles.includes(u.role)" :value="u.role">{{ u.role }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-3 data text-muted">{{ formatDate(u.created) }}</td>
|
||||
@@ -197,8 +212,7 @@ onMounted(load);
|
||||
<div>
|
||||
<label class="dh-label">Role</label>
|
||||
<select v-model="createForm.role" class="dh-input">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
<option v-for="r in assignableRoles" :key="r" :value="r">{{ r }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,6 @@ async function load() {
|
||||
loadError.value = "";
|
||||
try {
|
||||
profile.value = await refreshProfile();
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
loadError.value = e.message;
|
||||
} finally {
|
||||
@@ -208,49 +207,6 @@ async function saveBio() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Privacy & security: sessions ---
|
||||
|
||||
const sessions = ref([]);
|
||||
const sessionsError = ref("");
|
||||
const revokingId = ref("");
|
||||
const revokingOthers = ref(false);
|
||||
|
||||
async function loadSessions() {
|
||||
sessions.value = await api.listSessions();
|
||||
}
|
||||
|
||||
async function revokeSession(session) {
|
||||
if (!confirm(`Log out "${session.deviceLabel}"?`)) return;
|
||||
revokingId.value = session.id;
|
||||
sessionsError.value = "";
|
||||
try {
|
||||
await api.revokeSession(session.id);
|
||||
if (session.current) {
|
||||
onLogout();
|
||||
return;
|
||||
}
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
sessionsError.value = e.message;
|
||||
} finally {
|
||||
revokingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeOthers() {
|
||||
if (!confirm("Log out every other device? This device stays signed in.")) return;
|
||||
revokingOthers.value = true;
|
||||
sessionsError.value = "";
|
||||
try {
|
||||
await api.revokeOtherSessions();
|
||||
await loadSessions();
|
||||
} catch (e) {
|
||||
sessionsError.value = e.message;
|
||||
} finally {
|
||||
revokingOthers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onLogout() {
|
||||
logout();
|
||||
router.replace({ name: "login" });
|
||||
@@ -565,42 +521,17 @@ onBeforeUnmount(() => {
|
||||
<section class="dh-card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Privacy & security</h2>
|
||||
<button
|
||||
v-if="sessions.length > 1"
|
||||
class="text-sm font-medium text-brandtext hover:underline disabled:opacity-50"
|
||||
:disabled="revokingOthers"
|
||||
@click="revokeOthers"
|
||||
>
|
||||
{{ revokingOthers ? "Logging out…" : "Log out all other devices" }}
|
||||
<button class="text-sm font-medium text-danger hover:underline" @click="onLogout">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mb-3 text-sm text-muted">
|
||||
Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.
|
||||
<p class="text-sm text-muted">
|
||||
Two-factor authentication isn't available yet. Sessions are held as
|
||||
server-issued tokens that expire on their own, so signing out here ends
|
||||
this device's session only — there's no per-device list to revoke from.
|
||||
To lock out every device, change your password above.
|
||||
</p>
|
||||
|
||||
<p v-if="sessionsError" class="mb-3 text-sm text-danger">{{ sessionsError }}</p>
|
||||
|
||||
<ul class="divide-y divide-subtle">
|
||||
<li v-for="sess in sessions" :key="sess.id" class="flex items-center justify-between gap-3 py-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-strong">
|
||||
{{ sess.deviceLabel }}
|
||||
<span v-if="sess.current" class="dh-badge dh-badge-neutral ml-2">This device</span>
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
<span class="data">{{ sess.ip }}</span> · signed in {{ formatDate(sess.created) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 text-sm font-medium text-danger hover:underline disabled:opacity-50"
|
||||
:disabled="revokingId === sess.id"
|
||||
@click="revokeSession(sess)"
|
||||
>
|
||||
{{ revokingId === sess.id ? "Logging out…" : "Log out" }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Advanced / Danger Zone -->
|
||||
|
||||
Reference in New Issue
Block a user