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:
1 parent
7d55f0a4cd
commit
ae6ed4ac1e
56 files changed
+4474
-1475
No files matched your search
+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.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();/**
|
||||
* @vue/shared v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/function on(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const J={},xt=[],He=()=>{},ui=()=>!1,xs=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ws=e=>e.startsWith("onUpdate:"),de=Object.assign,rn=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},wo=Object.prototype.hasOwnProperty,W=(e,t)=>wo.call(e,t),U=Array.isArray,wt=e=>Zt(e)==="[object Map]",At=e=>Zt(e)==="[object Set]",$n=e=>Zt(e)==="[object Date]",H=e=>typeof e=="function",ee=e=>typeof e=="string",Ve=e=>typeof e=="symbol",z=e=>e!==null&&typeof e=="object",fi=e=>(z(e)||H(e))&&H(e.then)&&H(e.catch),di=Object.prototype.toString,Zt=e=>di.call(e),So=e=>Zt(e).slice(8,-1),pi=e=>Zt(e)==="[object Object]",ln=e=>ee(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Nt=on(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ss=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},To=/-\w/g,Ce=Ss(e=>e.replace(To,t=>t.slice(1).toUpperCase())),Co=/\B([A-Z])/g,rt=Ss(e=>e.replace(Co,"-$1").toLowerCase()),hi=Ss(e=>e.charAt(0).toUpperCase()+e.slice(1)),Is=Ss(e=>e?`on${hi(e)}`:""),Ne=(e,t)=>!Object.is(e,t),us=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},gi=(e,t,s,n=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},Ts=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let An;const Cs=()=>An||(An=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function an(e){if(U(e)){const t={};for(let s=0;s<e.length;s++){const n=e[s],i=ee(n)?$o(n):an(n);if(i)for(const o in i)t[o]=i[o]}return t}else if(ee(e)||z(e))return e}const Eo=/;(?![^(]*\))/g,ko=/:([^]+)/,Po=/\/\*[^]*?\*\//g;function $o(e){const t={};return e.replace(Po,"").split(Eo).forEach(s=>{if(s){const n=s.split(ko);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function je(e){let t="";if(ee(e))t=e;else if(U(e))for(let s=0;s<e.length;s++){const n=je(e[s]);n&&(t+=n+" ")}else if(z(e))for(const s in e)e[s]&&(t+=s+" ");return t.trim()}const Ao="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Oo=on(Ao);function mi(e){return!!e||e===""}function Mo(e,t){if(e.length!==t.length)return!1;let s=!0;for(let n=0;s&&n<e.length;n++)s=nt(e[n],t[n]);return s}function nt(e,t){if(e===t)return!0;let s=$n(e),n=$n(t);if(s||n)return s&&n?e.getTime()===t.getTime():!1;if(s=Ve(e),n=Ve(t),s||n)return e===t;if(s=U(e),n=U(t),s||n)return s&&n?Mo(e,t):!1;if(s=z(e),n=z(t),s||n){if(!s||!n)return!1;const i=Object.keys(e).length,o=Object.keys(t).length;if(i!==o)return!1;for(const r in e){const l=e.hasOwnProperty(r),a=t.hasOwnProperty(r);if(l&&!a||!l&&a||!nt(e[r],t[r]))return!1}}return String(e)===String(t)}function cn(e,t){return e.findIndex(s=>nt(s,t))}const bi=e=>!!(e&&e.__v_isRef===!0),D=e=>ee(e)?e:e==null?"":U(e)||z(e)&&(e.toString===di||!H(e.toString))?bi(e)?D(e.value):JSON.stringify(e,vi,2):String(e),vi=(e,t)=>bi(t)?vi(e,t.value):wt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,i],o)=>(s[Ds(n,o)+" =>"]=i,s),{})}:At(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ds(s))}:Ve(t)?Ds(t):z(t)&&!U(t)&&!pi(t)?String(t):t,Ds=(e,t="")=>{var s;return Ve(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/**
|
||||
* @vue/reactivity v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/let re;class Ro{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&re&&(re.active?(this.parent=re,this.index=(re.scopes||(re.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].pause();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].resume();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].resume()}}run(t){if(this._active){const s=re;try{return re=this,t()}finally{re=s}}}on(){++this._on===1&&(this.prevScope=re,re=this)}off(){if(this._on>0&&--this._on===0){if(re===this)re=this.prevScope;else{let t=re;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s<n;s++)this.effects[s].stop();for(this.effects.length=0,s=0,n=this.cleanups.length;s<n;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,n=this.scopes.length;s<n;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const i=this.parent.scopes.pop();i&&i!==this&&(this.parent.scopes[this.index]=i,i.index=this.index)}this.parent=void 0}}}function Io(){return re}let X;const Fs=new WeakSet;class _i{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,re&&(re.active?re.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Fs.has(this)&&(Fs.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||xi(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,On(this),wi(this);const t=X,s=Ee;X=this,Ee=!0;try{return this.fn()}finally{Si(this),X=t,Ee=s,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)dn(t);this.deps=this.depsTail=void 0,On(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Fs.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Gs(this)&&this.run()}get dirty(){return Gs(this)}}let yi=0,Ht,Vt;function xi(e,t=!1){if(e.flags|=8,t){e.next=Vt,Vt=e;return}e.next=Ht,Ht=e}function un(){yi++}function fn(){if(--yi>0)return;if(Vt){let t=Vt;for(Vt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Ht;){let t=Ht;for(Ht=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function wi(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Si(e){let t,s=e.depsTail,n=s;for(;n;){const i=n.prevDep;n.version===-1?(n===s&&(s=i),dn(n),Do(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=i}e.deps=t,e.depsTail=s}function Gs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Ti(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Ti(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Gt)||(e.globalVersion=Gt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Gs(e))))return;e.flags|=2;const t=e.dep,s=X,n=Ee;X=e,Ee=!0;try{wi(e);const i=e.fn(e._value);(t.version===0||Ne(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{X=s,Ee=n,Si(e),e.flags&=-3}}function dn(e,t=!1){const{dep:s,prevSub:n,nextSub:i}=e;if(n&&(n.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let o=s.computed.deps;o;o=o.nextDep)dn(o,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function Do(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let Ee=!0;const Ci=[];function Be(){Ci.push(Ee),Ee=!1}function Ke(){const e=Ci.pop();Ee=e===void 0?!0:e}function On(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=X;X=void 0;try{t()}finally{X=s}}}let Gt=0;class Fo{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class pn{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!X||!Ee||X===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==X)s=this.activeLink=new Fo(X,this),X.deps?(s.prevDep=X.depsTail,X.depsTail.nextDep=s,X.depsTail=s):X.deps=X.depsTail=s,Ei(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=X.depsTail,s.nextDep=void 0,X.depsTail.nextDep=s,X.depsTail=s,X.deps===s&&(X.deps=n)}return s}trigger(t){this.version++,Gt++,this.notify(t)}Line truncated
|
||||
* @vue/runtime-core v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/function Qt(e,t,s,n){try{return n?e(...n):e()}catch(i){ks(i,t,s)}}function Pe(e,t,s,n){if(H(e)){const i=Qt(e,t,s,n);return i&&fi(i)&&i.catch(o=>{ks(o,t,s)}),i}if(U(e)){const i=[];for(let o=0;o<e.length;o++)i.push(Pe(e[o],t,s,n));return i}}function ks(e,t,s,n=!0){const i=t?t.vnode:null,{errorHandler:o,throwUnhandledErrorInProduction:r}=t&&t.appContext.config||J;if(t){let l=t.parent;const a=t.proxy,p=`https://vuejs.org/error-reference/#runtime-${s}`;for(;l;){const u=l.ec;if(u){for(let h=0;h<u.length;h++)if(u[h](e,a,p)===!1)return}l=l.parent}if(o){Be(),Qt(o,null,10,[e,a,p]),Ke();return}}lr(e,s,i,n,r)}function lr(e,t,s,n=!0,i=!1){if(i)throw e;console.error(e)}const ge=[];let Fe=-1;const St=[];let st=null,yt=0;const Ii=Promise.resolve();let hs=null;function Di(e){const t=hs||Ii;return e?t.then(this?e.bind(this):e):t}function ar(e){let t=Fe+1,s=ge.length;for(;t<s;){const n=t+s>>>1,i=ge[n],o=Jt(i);o<e||o===e&&i.flags&2?t=n+1:s=n}return t}function bn(e){if(!(e.flags&1)){const t=Jt(e),s=ge[ge.length-1];!s||!(e.flags&2)&&t>=Jt(s)?ge.push(e):ge.splice(ar(t),0,e),e.flags|=1,Fi()}}function Fi(){hs||(hs=Ii.then(Ui))}function cr(e){U(e)?St.push(...e):st&&e.id===-1?st.splice(yt+1,0,e):e.flags&1||(St.push(e),e.flags|=1),Fi()}function Rn(e,t,s=Fe+1){for(;s<ge.length;s++){const n=ge[s];if(n&&n.flags&2){if(e&&n.id!==e.uid)continue;ge.splice(s,1),s--,n.flags&4&&(n.flags&=-2),n(),n.flags&4||(n.flags&=-2)}}}function Li(e){if(St.length){const t=[...new Set(St)].sort((s,n)=>Jt(s)-Jt(n));if(St.length=0,st){st.push(...t);return}for(st=t,yt=0;yt<st.length;yt++){const s=st[yt];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}st=null,yt=0}}const Jt=e=>e.id==null?e.flags&2?-1:1/0:e.id;function Ui(e){try{for(Fe=0;Fe<ge.length;Fe++){const t=ge[Fe];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),Qt(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;Fe<ge.length;Fe++){const t=ge[Fe];t&&(t.flags&=-2)}Fe=-1,ge.length=0,Li(),hs=null,(ge.length||St.length)&&Ui()}}let xe=null,Ni=null;function gs(e){const t=xe;return xe=e,Ni=e&&e.type.__scopeId||null,t}function ur(e,t=xe,s){if(!t||e._n)return e;const n=(...i)=>{n._d&&Kn(-1);const o=gs(t);let r;try{r=e(...i)}finally{gs(o),n._d&&Kn(1)}return r};return n._n=!0,n._c=!0,n._d=!0,n}function ue(e,t){if(xe===null)return e;const s=Os(xe),n=e.dirs||(e.dirs=[]);for(let i=0;i<t.length;i++){let[o,r,l,a=J]=t[i];o&&(H(o)&&(o={mounted:o,updated:o}),o.deep&&Je(r),n.push({dir:o,instance:s,value:r,oldValue:void 0,arg:l,modifiers:a}))}return e}function ut(e,t,s,n){const i=e.dirs,o=t&&t.dirs;for(let r=0;r<i.length;r++){const l=i[r];o&&(l.oldValue=o[r].value);let a=l.dir[n];a&&(Be(),Pe(a,s,8,[e.el,l,e,t]),Ke())}}function fr(e,t){if(me){let s=me.provides;const n=me.parent&&me.parent.provides;n===s&&(s=me.provides=Object.create(n)),s[e]=t}}function fs(e,t,s=!1){const n=al();if(n||Tt){let i=Tt?Tt._context.provides:n?n.parent==null||n.ce?n.vnode.appContext&&n.vnode.appContext.provides:n.parent.provides:void 0;if(i&&e in i)return i[e];if(arguments.length>1)return s&&H(t)?t.call(n&&n.proxy):t}}const dr=Symbol.for("v-scx"),pr=()=>fs(dr);function Ns(e,t,s){return Hi(e,t,s)}function Hi(e,t,s=J){const{immediate:n,deep:i,flush:o,once:r}=s,l=de({},s),a=t&&n||!t&&o!=="post";let p;if(Xt){if(o==="sync"){const $=pr();p=$.__watcherHandles||($.__watcherHandles=[])}else if(!a){const $=()=>{};return $.stop=He,$.resume=He,$.pause=He,$}}const u=me;l.call=($,F,y)=>Pe($,u,F,y);let h=!1;o==="post"?l.scheduler=$=>{be($,u&&u.suspense)}:o!=="sync"&&(h=!0,l.scheduler=($,F)=>{F?$():bn($)}),l.augmentJob=$=>{t&&($.flags|=4),h&&($.flags|=2,u&&($.id=u.uid,$.i=u))};const T=rr(e,t,l);return Xt&&(p?p.push(T):a&&T()),T}function hr(e,t,s){const n=this.proxy,i=ee(e)?e.includes(".")?Vi(n,e):()=>n[e]:e.bind(n,n);let o;H(t)?o=t:(o=t.handler,s=t);const r=es(this),l=Hi(i,o.bind(n),s);return r(),l}function Vi(e,t){const s=t.split(".");return()=>{let n=e;for(let i=0;i<s.length&&n;i++)n=n[s[i]];return n}}const gr=Symbol("_vte"),mr=e=>e.__isTeleport,Hs=Symbol("_leaveCb");function vn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,vn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function ji(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function In(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const ms=new WeakMap;function jt(e,t,s,n,i=!1){if(U(e)){e.forEach((y,S)=>jt(y,t&&(U(t)?t[S]:t),s,n,i));return}if(Bt(n)&&!i){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&jt(e,t,s,n.component.subTree);return}const o=n.shapeFlag&4?Os(n.component):n.el,r=i?null:o,{i:l,r:a}=e,p=t&&t.r,u=l.refs===J?l.refs={}:l.refs,h=l.setupState,T=K(h),$=h===J?ui:y=>In(u,y)?!1:W(T,y),F=(y,S)=>!(S&&In(u,S));if(p!=null&&p!==a){if(Dn(t),ee(p))u[p]=null,$(p)&&(h[p]=null);else if(fe(p)){const y=t;F(p,y.k)&&(p.value=null),y.k&&(u[y.k]=null)}}if(H(a)){Be();try{Qt(a,l,12,[r,u])}finally{Ke()}}else{const y=ee(a),S=fe(a);if(y||S){const x=()=>{if(e.f){con
Line truncated
|
||||
* @vue/runtime-dom v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/let sn;const zn=typeof window<"u"&&window.trustedTypes;if(zn)try{sn=zn.createPolicy("vue",{createHTML:e=>e})}catch{}const ho=sn?e=>sn.createHTML(e):e=>e,gl="http://www.w3.org/2000/svg",ml="http://www.w3.org/1998/Math/MathML",Ge=typeof document<"u"?document:null,qn=Ge&&Ge.createElement("template"),bl={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?Ge.createElementNS(gl,e):t==="mathml"?Ge.createElementNS(ml,e):s?Ge.createElement(e,{is:s}):Ge.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>Ge.createTextNode(e),createComment:e=>Ge.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ge.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,o){const r=s?s.previousSibling:t.lastChild;if(i&&(i===o||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===o||!(i=i.nextSibling)););else{qn.innerHTML=ho(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const l=qn.content;if(n==="svg"||n==="mathml"){const a=l.firstChild;for(;a.firstChild;)l.appendChild(a.firstChild);l.removeChild(a)}t.insertBefore(l,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},vl=Symbol("_vtc");function _l(e,t,s){const n=e[vl];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const Jn=Symbol("_vod"),yl=Symbol("_vsh"),xl=Symbol(""),wl=/(?:^|;)\s*display\s*:/;function Sl(e,t,s){const n=e.style,i=ee(s);let o=!1;if(s&&!i){if(t)if(ee(t))for(const r of t.split(";")){const l=r.slice(0,r.indexOf(":")).trim();s[l]==null&&Ut(n,l,"")}else for(const r in t)s[r]==null&&Ut(n,r,"");for(const r in s){r==="display"&&(o=!0);const l=s[r];l!=null?Cl(e,r,!ee(t)&&t?t[r]:void 0,l)||Ut(n,r,l):Ut(n,r,"")}}else if(i){if(t!==s){const r=n[xl];r&&(s+=";"+r),n.cssText=s,o=wl.test(s)}}else t&&e.removeAttribute("style");Jn in e&&(e[Jn]=o?n.display:"",e[yl]&&(n.display="none"))}const Yn=/\s*!important$/;function Ut(e,t,s){if(U(s))s.forEach(n=>Ut(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Tl(e,t);Yn.test(s)?e.setProperty(rt(n),s.replace(Yn,""),"important"):e[n]=s}}const Xn=["Webkit","Moz","ms"],Ks={};function Tl(e,t){const s=Ks[t];if(s)return s;let n=Ce(t);if(n!=="filter"&&n in e)return Ks[t]=n;n=hi(n);for(let i=0;i<Xn.length;i++){const o=Xn[i]+n;if(o in e)return Ks[t]=o}return t}function Cl(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&ee(n)&&s===n}const Zn="http://www.w3.org/1999/xlink";function Qn(e,t,s,n,i,o=Oo(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(Zn,t.slice(6,t.length)):e.setAttributeNS(Zn,t,s):s==null||o&&!mi(s)?e.removeAttribute(t):e.setAttribute(t,o?"":Ve(s)?String(s):s)}function ei(e,t,s,n,i){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?ho(s):s);return}const o=e.tagName;if(t==="value"&&o!=="PROGRESS"&&!o.includes("-")){const l=o==="OPTION"?e.getAttribute("value")||"":e.value,a=s==null?e.type==="checkbox"?"on":"":String(s);(l!==a||!("_value"in e))&&(e.value=a),s==null&&e.removeAttribute(t),e._value=s;return}let r=!1;if(s===""||s==null){const l=typeof e[t];l==="boolean"?s=mi(s):s==null&&l==="string"?(s="",r=!0):l==="number"&&(s=0,r=!0)}try{e[t]=s}catch{}r&&e.removeAttribute(i||t)}function Ye(e,t,s,n){e.addEventListener(t,s,n)}function El(e,t,s,n){e.removeEventListener(t,s,n)}const ti=Symbol("_vei");function kl(e,t,s,n,i=null){const o=e[ti]||(e[ti]={}),r=o[t];if(n&&r)r.value=n;else{const[l,a]=Al(t);if(n){const p=o[t]=Rl(n,i);Ye(e,l,p,a)}else r&&(El(e,l,r,a),o[t]=void 0)}}const Pl=/(Once|Passive|Capture)$/,$l=/^on:?(?:Once|Passive|Capture)$/;function Al(e){let t,s;for(;(s=e.match(Pl))&&!$l.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):rt(e.slice(2)),t]}let Ws=0;const Ol=Promise.resolve(),Ml=()=>Ws||(Ol.then(()=>Ws=0),Ws=Date.now());function Rl(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(U(i)){const o=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{o.call(n),n._stopped=!0};const r=i.slice(),l=[n];for(let a=0;a<r.length&&!n._stopped;a++){const p=r[a];p&&Pe(p,t,5,l)}}else Pe(i,t,5,[n])};return s.value=e,s.attached=Ml(),s}const si=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Il=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?_l(e,n,r):t==="style"?Sl(e,s,n):xs(t)?ws(t)||kl(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Dl(e,t,n,r))?(ei(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Qn(e,t,n,r,o,t!=="value")):e._isVueCE&&(Fl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ee(n)))?ei(e,Ce(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._fals
Line truncated
|
||||
@@ -1 +0,0 @@
|
||||
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--shadow-xs:var(--shadow-xs);--shadow-sm:var(--shadow-sm);--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius-control:12px;--radius-card:18px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-inline:auto}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.flex{display:flex}.inline-flex{display:inlLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:.25rem;--container-sm:24rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--shadow-xs:var(--shadow-xs);--shadow-sm:var(--shadow-sm);--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius-control:12px;--radius-card:18px;--radius-pill:999px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-Line truncated
|
||||
@@ -1,17 +0,0 @@
|
||||
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))n(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function s(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(r){if(r.ep)return;r.ep=!0;const i=s(r);fetch(r.href,i)}})();/**
|
||||
* @vue/shared v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/function Ls(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const V={},nt=[],Pe=()=>{},jn=()=>!1,es=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ts=e=>e.startsWith("onUpdate:"),se=Object.assign,Hs=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},qr=Object.prototype.hasOwnProperty,$=(e,t)=>qr.call(e,t),R=Array.isArray,rt=e=>It(e)==="[object Map]",$n=e=>It(e)==="[object Set]",ln=e=>It(e)==="[object Date]",F=e=>typeof e=="function",J=e=>typeof e=="string",Me=e=>typeof e=="symbol",U=e=>e!==null&&typeof e=="object",Nn=e=>(U(e)||F(e))&&F(e.then)&&F(e.catch),Un=Object.prototype.toString,It=e=>Un.call(e),Jr=e=>It(e).slice(8,-1),Wn=e=>It(e)==="[object Object]",js=e=>J(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,xt=Ls(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),ss=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},Yr=/-\w/g,de=ss(e=>e.replace(Yr,t=>t.slice(1).toUpperCase())),zr=/\B([A-Z])/g,et=ss(e=>e.replace(zr,"-$1").toLowerCase()),Bn=ss(e=>e.charAt(0).toUpperCase()+e.slice(1)),as=ss(e=>e?`on${Bn(e)}`:""),Ce=(e,t)=>!Object.is(e,t),ds=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},Kn=(e,t,s,n=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},Xr=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let cn;const ns=()=>cn||(cn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function $s(e){if(R(e)){const t={};for(let s=0;s<e.length;s++){const n=e[s],r=J(n)?ti(n):$s(n);if(r)for(const i in r)t[i]=r[i]}return t}else if(J(e)||U(e))return e}const Zr=/;(?![^(]*\))/g,Qr=/:([^]+)/,ei=/\/\*[^]*?\*\//g;function ti(e){const t={};return e.replace(ei,"").split(Zr).forEach(s=>{if(s){const n=s.split(Qr);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function Ft(e){let t="";if(J(e))t=e;else if(R(e))for(let s=0;s<e.length;s++){const n=Ft(e[s]);n&&(t+=n+" ")}else if(U(e))for(const s in e)e[s]&&(t+=s+" ");return t.trim()}const si="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",ni=Ls(si);function Vn(e){return!!e||e===""}function ri(e,t){if(e.length!==t.length)return!1;let s=!0;for(let n=0;s&&n<e.length;n++)s=Ns(e[n],t[n]);return s}function Ns(e,t){if(e===t)return!0;let s=ln(e),n=ln(t);if(s||n)return s&&n?e.getTime()===t.getTime():!1;if(s=Me(e),n=Me(t),s||n)return e===t;if(s=R(e),n=R(t),s||n)return s&&n?ri(e,t):!1;if(s=U(e),n=U(t),s||n){if(!s||!n)return!1;const r=Object.keys(e).length,i=Object.keys(t).length;if(r!==i)return!1;for(const o in e){const l=e.hasOwnProperty(o),f=t.hasOwnProperty(o);if(l&&!f||!l&&f||!Ns(e[o],t[o]))return!1}}return String(e)===String(t)}const Gn=e=>!!(e&&e.__v_isRef===!0),Ae=e=>J(e)?e:e==null?"":R(e)||U(e)&&(e.toString===Un||!F(e.toString))?Gn(e)?Ae(e.value):JSON.stringify(e,kn,2):String(e),kn=(e,t)=>Gn(t)?kn(e,t.value):rt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,r],i)=>(s[hs(n,i)+" =>"]=r,s),{})}:$n(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>hs(s))}:Me(t)?hs(t):U(t)&&!R(t)&&!Wn(t)?String(t):t,hs=(e,t="")=>{var s;return Me(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/**
|
||||
* @vue/reactivity v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/let Z;class ii{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&Z&&(Z.active?(this.parent=Z,this.index=(Z.scopes||(Z.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].pause();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].resume();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].resume()}}run(t){if(this._active){const s=Z;try{return Z=this,t()}finally{Z=s}}}on(){++this._on===1&&(this.prevScope=Z,Z=this)}off(){if(this._on>0&&--this._on===0){if(Z===this)Z=this.prevScope;else{let t=Z;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s<n;s++)this.effects[s].stop();for(this.effects.length=0,s=0,n=this.cleanups.length;s<n;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,n=this.scopes.length;s<n;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const r=this.parent.scopes.pop();r&&r!==this&&(this.parent.scopes[this.index]=r,r.index=this.index)}this.parent=void 0}}}function oi(){return Z}let K;const ps=new WeakSet;class qn{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,Z&&(Z.active?Z.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,ps.has(this)&&(ps.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||Yn(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,fn(this),zn(this);const t=K,s=he;K=this,he=!0;try{return this.fn()}finally{Xn(this),K=t,he=s,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)Bs(t);this.deps=this.depsTail=void 0,fn(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?ps.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Ss(this)&&this.run()}get dirty(){return Ss(this)}}let Jn=0,yt,wt;function Yn(e,t=!1){if(e.flags|=8,t){e.next=wt,wt=e;return}e.next=yt,yt=e}function Us(){Jn++}function Ws(){if(--Jn>0)return;if(wt){let t=wt;for(wt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;yt;){let t=yt;for(yt=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function zn(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Xn(e){let t,s=e.depsTail,n=s;for(;n;){const r=n.prevDep;n.version===-1?(n===s&&(s=r),Bs(n),li(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=r}e.deps=t,e.depsTail=s}function Ss(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Zn(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Zn(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===At)||(e.globalVersion=At,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Ss(e))))return;e.flags|=2;const t=e.dep,s=K,n=he;K=e,he=!0;try{zn(e);const r=e.fn(e._value);(t.version===0||Ce(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{K=s,he=n,Xn(e),e.flags&=-3}}function Bs(e,t=!1){const{dep:s,prevSub:n,nextSub:r}=e;if(n&&(n.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)Bs(i,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function li(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let he=!0;const Qn=[];function Re(){Qn.push(he),he=!1}function Ie(){const e=Qn.pop();he=e===void 0?!0:e}function fn(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=K;K=void 0;try{t()}finally{K=s}}}let At=0;class ci{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Ks{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!K||!he||K===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==K)s=this.activeLink=new ci(K,this),K.deps?(s.prevDep=K.depsTail,K.depsTail.nextDep=s,K.depsTail=s):K.deps=K.depsTail=s,er(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=K.depsTail,s.nextDep=void 0,K.depsTail.nextDep=s,K.depsTail=s,K.deps===s&&(K.deps=n)}return s}trigger(t){this.version++,At++,this.notify(t)}notify(t){Us();tryLine truncated
|
||||
* @vue/runtime-core v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/function Dt(e,t,s,n){try{return n?e(...n):e()}catch(r){is(r,t,s)}}function ge(e,t,s,n){if(F(e)){const r=Dt(e,t,s,n);return r&&Nn(r)&&r.catch(i=>{is(i,t,s)}),r}if(R(e)){const r=[];for(let i=0;i<e.length;i++)r.push(ge(e[i],t,s,n));return r}}function is(e,t,s,n=!0){const r=t?t.vnode:null,{errorHandler:i,throwUnhandledErrorInProduction:o}=t&&t.appContext.config||V;if(t){let l=t.parent;const f=t.proxy,d=`https://vuejs.org/error-reference/#runtime-${s}`;for(;l;){const a=l.ec;if(a){for(let p=0;p<a.length;p++)if(a[p](e,f,d)===!1)return}l=l.parent}if(i){Re(),Dt(i,null,10,[e,f,d]),Ie();return}}Fi(e,s,r,n,o)}function Fi(e,t,s,n=!0,r=!1){if(r)throw e;console.error(e)}const ie=[];let Te=-1;const it=[];let We=null,st=0;const cr=Promise.resolve();let Jt=null;function Di(e){const t=Jt||cr;return e?t.then(this?e.bind(this):e):t}function Li(e){let t=Te+1,s=ie.length;for(;t<s;){const n=t+s>>>1,r=ie[n],i=Pt(r);i<e||i===e&&r.flags&2?t=n+1:s=n}return t}function Js(e){if(!(e.flags&1)){const t=Pt(e),s=ie[ie.length-1];!s||!(e.flags&2)&&t>=Pt(s)?ie.push(e):ie.splice(Li(t),0,e),e.flags|=1,fr()}}function fr(){Jt||(Jt=cr.then(ar))}function Hi(e){R(e)?it.push(...e):We&&e.id===-1?We.splice(st+1,0,e):e.flags&1||(it.push(e),e.flags|=1),fr()}function an(e,t,s=Te+1){for(;s<ie.length;s++){const n=ie[s];if(n&&n.flags&2){if(e&&n.id!==e.uid)continue;ie.splice(s,1),s--,n.flags&4&&(n.flags&=-2),n(),n.flags&4||(n.flags&=-2)}}}function ur(e){if(it.length){const t=[...new Set(it)].sort((s,n)=>Pt(s)-Pt(n));if(it.length=0,We){We.push(...t);return}for(We=t,st=0;st<We.length;st++){const s=We[st];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}We=null,st=0}}const Pt=e=>e.id==null?e.flags&2?-1:1/0:e.id;function ar(e){try{for(Te=0;Te<ie.length;Te++){const t=ie[Te];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),Dt(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;Te<ie.length;Te++){const t=ie[Te];t&&(t.flags&=-2)}Te=-1,ie.length=0,ur(),Jt=null,(ie.length||it.length)&&ar()}}let Oe=null,dr=null;function Yt(e){const t=Oe;return Oe=e,dr=e&&e.type.__scopeId||null,t}function ji(e,t=Oe,s){if(!t||e._n)return e;const n=(...r)=>{n._d&&wn(-1);const i=Yt(t);let o;try{o=e(...r)}finally{Yt(i),n._d&&wn(1)}return o};return n._n=!0,n._c=!0,n._d=!0,n}function Je(e,t,s,n){const r=e.dirs,i=t&&t.dirs;for(let o=0;o<r.length;o++){const l=r[o];i&&(l.oldValue=i[o].value);let f=l.dir[n];f&&(Re(),ge(f,s,8,[e.el,l,e,t]),Ie())}}function $i(e,t){if(oe){let s=oe.provides;const n=oe.parent&&oe.parent.provides;n===s&&(s=oe.provides=Object.create(n)),s[e]=t}}function Vt(e,t,s=!1){const n=No();if(n||ot){let r=ot?ot._context.provides:n?n.parent==null||n.ce?n.vnode.appContext&&n.vnode.appContext.provides:n.parent.provides:void 0;if(r&&e in r)return r[e];if(arguments.length>1)return s&&F(t)?t.call(n&&n.proxy):t}}const Ni=Symbol.for("v-scx"),Ui=()=>Vt(Ni);function _s(e,t,s){return hr(e,t,s)}function hr(e,t,s=V){const{immediate:n,deep:r,flush:i,once:o}=s,l=se({},s),f=t&&n||!t&&i!=="post";let d;if(Rt){if(i==="sync"){const S=Ui();d=S.__watcherHandles||(S.__watcherHandles=[])}else if(!f){const S=()=>{};return S.stop=Pe,S.resume=Pe,S.pause=Pe,S}}const a=oe;l.call=(S,H,M)=>ge(S,a,H,M);let p=!1;i==="post"?l.scheduler=S=>{le(S,a&&a.suspense)}:i!=="sync"&&(p=!0,l.scheduler=(S,H)=>{H?S():Js(S)}),l.augmentJob=S=>{t&&(S.flags|=4),p&&(S.flags|=2,a&&(S.id=a.uid,S.i=a))};const T=Ii(e,t,l);return Rt&&(d?d.push(T):f&&T()),T}function Wi(e,t,s){const n=this.proxy,r=J(e)?e.includes(".")?pr(n,e):()=>n[e]:e.bind(n,n);let i;F(t)?i=t:(i=t.handler,s=t);const o=Lt(this),l=hr(r,i.bind(n),s);return o(),l}function pr(e,t){const s=t.split(".");return()=>{let n=e;for(let r=0;r<s.length&&n;r++)n=n[s[r]];return n}}const Bi=Symbol("_vte"),Ki=e=>e.__isTeleport,bs=Symbol("_leaveCb");function Ys(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Ys(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function gr(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function dn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const zt=new WeakMap;function Tt(e,t,s,n,r=!1){if(R(e)){e.forEach((M,k)=>Tt(M,t&&(R(t)?t[k]:t),s,n,r));return}if(St(n)&&!r){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&Tt(e,t,s,n.component.subTree);return}const i=n.shapeFlag&4?Qs(n.component):n.el,o=r?null:i,{i:l,r:f}=e,d=t&&t.r,a=l.refs===V?l.refs={}:l.refs,p=l.setupState,T=j(p),S=p===V?jn:M=>dn(a,M)?!1:$(T,M),H=(M,k)=>!(k&&dn(a,k));if(d!=null&&d!==f){if(hn(t),J(d))a[d]=null,S(d)&&(p[d]=null);else if(te(d)){const M=t;H(d,M.k)&&(d.value=null),M.k&&(a[M.k]=null)}}if(F(f)){Re();try{Dt(f,l,12,[o,a])}finally{Ie()}}else{const M=J(f),k=te(f);if(M||k){const C=()=>{if(e.f){const D=M?S(f)?p[f]:a[f]:H()||!e.k?f.value:a[e.k];if(r)R(D)&&Hs(D,i);else if(R(D))D.includes(i)||D.push(i);else if(M)a[f]=[i],S(f)&&(p[f]=a[f]);else{const G=[i];H(f,e.k)&&(f.value=G),e.k&&(a[e.k]=G)}}else M?(a[f]=o,S(f)&&(p[f]=o)):k&&(H(f,e.k)&&(f.value=o),e.
Line truncated
|
||||
* @vue/runtime-dom v3.5.39
|
||||
* (c) 2018-present Yuxi (Evan) You and Vue contributors
|
||||
* @license MIT
|
||||
**/let Ds;const En=typeof window<"u"&&window.trustedTypes;if(En)try{Ds=En.createPolicy("vue",{createHTML:e=>e})}catch{}const Br=Ds?e=>Ds.createHTML(e):e=>e,ko="http://www.w3.org/2000/svg",qo="http://www.w3.org/1998/Math/MathML",De=typeof document<"u"?document:null,Cn=De&&De.createElement("template"),Jo={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const r=t==="svg"?De.createElementNS(ko,e):t==="mathml"?De.createElementNS(qo,e):s?De.createElement(e,{is:s}):De.createElement(e);return e==="select"&&n&&n.multiple!=null&&r.setAttribute("multiple",n.multiple),r},createText:e=>De.createTextNode(e),createComment:e=>De.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>De.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,r,i){const o=s?s.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),s),!(r===i||!(r=r.nextSibling)););else{Cn.innerHTML=Br(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const l=Cn.content;if(n==="svg"||n==="mathml"){const f=l.firstChild;for(;f.firstChild;)l.appendChild(f.firstChild);l.removeChild(f)}t.insertBefore(l,s)}return[o?o.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Yo=Symbol("_vtc");function zo(e,t,s){const n=e[Yo];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const An=Symbol("_vod"),Xo=Symbol("_vsh"),Zo=Symbol(""),Qo=/(?:^|;)\s*display\s*:/;function el(e,t,s){const n=e.style,r=J(s);let i=!1;if(s&&!r){if(t)if(J(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();s[l]==null&&vt(n,l,"")}else for(const o in t)s[o]==null&&vt(n,o,"");for(const o in s){o==="display"&&(i=!0);const l=s[o];l!=null?sl(e,o,!J(t)&&t?t[o]:void 0,l)||vt(n,o,l):vt(n,o,"")}}else if(r){if(t!==s){const o=n[Zo];o&&(s+=";"+o),n.cssText=s,i=Qo.test(s)}}else t&&e.removeAttribute("style");An in e&&(e[An]=i?n.display:"",e[Xo]&&(n.display="none"))}const On=/\s*!important$/;function vt(e,t,s){if(R(s))s.forEach(n=>vt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=tl(e,t);On.test(s)?e.setProperty(et(n),s.replace(On,""),"important"):e[n]=s}}const Pn=["Webkit","Moz","ms"],ws={};function tl(e,t){const s=ws[t];if(s)return s;let n=de(t);if(n!=="filter"&&n in e)return ws[t]=n;n=Bn(n);for(let r=0;r<Pn.length;r++){const i=Pn[r]+n;if(i in e)return ws[t]=i}return t}function sl(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&J(n)&&s===n}const Mn="http://www.w3.org/1999/xlink";function Rn(e,t,s,n,r,i=ni(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(Mn,t.slice(6,t.length)):e.setAttributeNS(Mn,t,s):s==null||i&&!Vn(s)?e.removeAttribute(t):e.setAttribute(t,i?"":Me(s)?String(s):s)}function In(e,t,s,n,r){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?Br(s):s);return}const i=e.tagName;if(t==="value"&&i!=="PROGRESS"&&!i.includes("-")){const l=i==="OPTION"?e.getAttribute("value")||"":e.value,f=s==null?e.type==="checkbox"?"on":"":String(s);(l!==f||!("_value"in e))&&(e.value=f),s==null&&e.removeAttribute(t),e._value=s;return}let o=!1;if(s===""||s==null){const l=typeof e[t];l==="boolean"?s=Vn(s):s==null&&l==="string"?(s="",o=!0):l==="number"&&(s=0,o=!0)}try{e[t]=s}catch{}o&&e.removeAttribute(r||t)}function nl(e,t,s,n){e.addEventListener(t,s,n)}function rl(e,t,s,n){e.removeEventListener(t,s,n)}const Fn=Symbol("_vei");function il(e,t,s,n,r=null){const i=e[Fn]||(e[Fn]={}),o=i[t];if(n&&o)o.value=n;else{const[l,f]=cl(t);if(n){const d=i[t]=al(n,r);nl(e,l,d,f)}else o&&(rl(e,l,o,f),i[t]=void 0)}}const ol=/(Once|Passive|Capture)$/,ll=/^on:?(?:Once|Passive|Capture)$/;function cl(e){let t,s;for(;(s=e.match(ol))&&!ll.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):et(e.slice(2)),t]}let Ts=0;const fl=Promise.resolve(),ul=()=>Ts||(fl.then(()=>Ts=0),Ts=Date.now());function al(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const r=s.value;if(R(r)){const i=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{i.call(n),n._stopped=!0};const o=r.slice(),l=[n];for(let f=0;f<o.length&&!n._stopped;f++){const d=o[f];d&&ge(d,t,5,l)}}else ge(r,t,5,[n])};return s.value=e,s.attached=ul(),s}const Dn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,dl=(e,t,s,n,r,i)=>{const o=r==="svg";t==="class"?zo(e,n,o):t==="style"?el(e,s,n):es(t)?ts(t)||il(e,t,s,n,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):hl(e,t,n,o))?(In(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Rn(e,t,n,o,i,t!=="value")):e._isVueCE&&(pl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!J(n)))?In(e,de(t),n,i,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValu
Line truncated
|
||||
+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