diff --git a/API Server/.env.example b/API Server/.env.example index 29bc2d2..97bb161 100644 --- a/API Server/.env.example +++ b/API Server/.env.example @@ -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. diff --git a/API Server/.gitignore b/API Server/.gitignore index 4c7c53b..1c537be 100644 --- a/API Server/.gitignore +++ b/API Server/.gitignore @@ -1,4 +1,7 @@ .env +plugins.json *.exe +*.log /tmp/ /bin/ +panel/node_modules/ diff --git a/API Server/README.md b/API Server/README.md index d170570..ef73156 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -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: ) -> { id, email, name, role } +GET /api/identity (Authorization: ) -> + organization +``` + +Both `Authorization: Bearer ` and a raw `Authorization: ` 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 ) -> { 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= -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 "Name" # create an app login -node scripts/set-role.mjs 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 "Name" # create an app login +node scripts/set-role.mjs 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. diff --git a/API Server/cmd/server/main.go b/API Server/cmd/server/main.go new file mode 100644 index 0000000..a23e0f4 --- /dev/null +++ b/API Server/cmd/server/main.go @@ -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") +} diff --git a/API Server/go.mod b/API Server/go.mod index f94ed0e..872d4b1 100644 --- a/API Server/go.mod +++ b/API Server/go.mod @@ -1,3 +1,3 @@ -module carcontrol/api +module drivervault/apiserver -go 1.22 +go 1.26 diff --git a/API Server/internal/api/admin.go b/API Server/internal/api/admin.go deleted file mode 100644 index d4ccf94..0000000 --- a/API Server/internal/api/admin.go +++ /dev/null @@ -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 -} diff --git a/API Server/internal/api/auth.go b/API Server/internal/api/auth.go index 5d4733c..e993cd4 100644 --- a/API Server/internal/api/auth.go +++ b/API Server/internal/api/auth.go @@ -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 " 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 " 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: ) +// 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) } diff --git a/API Server/internal/api/cars.go b/API Server/internal/api/cars.go index 18d2c45..6db6970 100644 --- a/API Server/internal/api/cars.go +++ b/API Server/internal/api/cars.go @@ -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. diff --git a/API Server/internal/api/dist/assets/index-DKHgRvVM.js b/API Server/internal/api/dist/assets/index-DKHgRvVM.js new file mode 100644 index 0000000..a19ffd6 --- /dev/null +++ b/API Server/internal/api/dist/assets/index-DKHgRvVM.js @@ -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{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{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;snt(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;t0&&--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;s0)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)}notify(t){un();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{fn()}}}function Ei(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let n=t.deps;n;n=n.nextDep)Ei(n)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const zs=new WeakMap,gt=Symbol(""),qs=Symbol(""),zt=Symbol("");function ae(e,t,s){if(Ee&&X){let n=zs.get(e);n||zs.set(e,n=new Map);let i=n.get(s);i||(n.set(s,i=new pn),i.map=n,i.key=s),i.track()}}function qe(e,t,s,n,i,o){const r=zs.get(e);if(!r){Gt++;return}const l=a=>{a&&a.trigger()};if(un(),t==="clear")r.forEach(l);else{const a=U(e),p=a&&ln(s);if(a&&s==="length"){const u=Number(n);r.forEach((h,T)=>{(T==="length"||T===zt||!Ve(T)&&T>=u)&&l(h)})}else switch((s!==void 0||r.has(void 0))&&l(r.get(s)),p&&l(r.get(zt)),t){case"add":a?p&&l(r.get("length")):(l(r.get(gt)),wt(e)&&l(r.get(qs)));break;case"delete":a||(l(r.get(gt)),wt(e)&&l(r.get(qs)));break;case"set":wt(e)&&l(r.get(gt));break}}fn()}function _t(e){const t=K(e);return t===e?t:(ae(t,"iterate",zt),we(e)?t:t.map(ke))}function Es(e){return ae(e=K(e),"iterate",zt),e}function Le(e,t){return Xe(e)?Et(mt(e)?ke(t):t):ke(t)}const Lo={__proto__:null,[Symbol.iterator](){return Ls(this,Symbol.iterator,e=>Le(this,e))},concat(...e){return _t(this).concat(...e.map(t=>U(t)?_t(t):t))},entries(){return Ls(this,"entries",e=>(e[1]=Le(this,e[1]),e))},every(e,t){return We(this,"every",e,t,void 0,arguments)},filter(e,t){return We(this,"filter",e,t,s=>s.map(n=>Le(this,n)),arguments)},find(e,t){return We(this,"find",e,t,s=>Le(this,s),arguments)},findIndex(e,t){return We(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return We(this,"findLast",e,t,s=>Le(this,s),arguments)},findLastIndex(e,t){return We(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return We(this,"forEach",e,t,void 0,arguments)},includes(...e){return Us(this,"includes",e)},indexOf(...e){return Us(this,"indexOf",e)},join(e){return _t(this).join(e)},lastIndexOf(...e){return Us(this,"lastIndexOf",e)},map(e,t){return We(this,"map",e,t,void 0,arguments)},pop(){return Dt(this,"pop")},push(...e){return Dt(this,"push",e)},reduce(e,...t){return Mn(this,"reduce",e,t)},reduceRight(e,...t){return Mn(this,"reduceRight",e,t)},shift(){return Dt(this,"shift")},some(e,t){return We(this,"some",e,t,void 0,arguments)},splice(...e){return Dt(this,"splice",e)},toReversed(){return _t(this).toReversed()},toSorted(e){return _t(this).toSorted(e)},toSpliced(...e){return _t(this).toSpliced(...e)},unshift(...e){return Dt(this,"unshift",e)},values(){return Ls(this,"values",e=>Le(this,e))}};function Ls(e,t,s){const n=Es(e),i=n[t]();return n!==e&&!we(e)&&(i._next=i.next,i.next=()=>{const o=i._next();return o.done||(o.value=s(o.value)),o}),i}const Uo=Array.prototype;function We(e,t,s,n,i,o){const r=Es(e),l=r!==e&&!we(e),a=r[t];if(a!==Uo[t]){const h=a.apply(e,o);return l?ke(h):h}let p=s;r!==e&&(l?p=function(h,T){return s.call(this,Le(e,h),T,e)}:s.length>2&&(p=function(h,T){return s.call(this,h,T,e)}));const u=a.call(r,p,n);return l&&i?i(u):u}function Mn(e,t,s,n){const i=Es(e),o=i!==e&&!we(e);let r=s,l=!1;i!==e&&(o?(l=n.length===0,r=function(p,u,h){return l&&(l=!1,p=Le(e,p)),s.call(this,p,Le(e,u),h,e)}):s.length>3&&(r=function(p,u,h){return s.call(this,p,u,h,e)}));const a=i[t](r,...n);return l?Le(e,a):a}function Us(e,t,s){const n=K(e);ae(n,"iterate",zt);const i=n[t](...s);return(i===-1||i===!1)&&mn(s[0])?(s[0]=K(s[0]),n[t](...s)):i}function Dt(e,t,s=[]){Be(),un();const n=K(e)[t].apply(e,s);return fn(),Ke(),n}const No=on("__proto__,__v_isRef,__isVue"),ki=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Ve));function Ho(e){Ve(e)||(e=String(e));const t=K(this);return ae(t,"has",e),t.hasOwnProperty(e)}class Pi{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,n){if(s==="__v_skip")return t.__v_skip;const i=this._isReadonly,o=this._isShallow;if(s==="__v_isReactive")return!i;if(s==="__v_isReadonly")return i;if(s==="__v_isShallow")return o;if(s==="__v_raw")return n===(i?o?Yo:Mi:o?Oi:Ai).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(n)?t:void 0;const r=U(t);if(!i){let a;if(r&&(a=Lo[s]))return a;if(s==="hasOwnProperty")return Ho}const l=Reflect.get(t,s,fe(t)?t:n);if((Ve(s)?ki.has(s):No(s))||(i||ae(t,"get",s),o))return l;if(fe(l)){const a=r&&ln(s)?l:l.value;return i&&z(a)?Ys(a):a}return z(l)?i?Ys(l):qt(l):l}}class $i extends Pi{constructor(t=!1){super(!1,t)}set(t,s,n,i){let o=t[s];const r=U(t)&&ln(s);if(!this._isShallow){const p=Xe(o);if(!we(n)&&!Xe(n)&&(o=K(o),n=K(n)),!r&&fe(o)&&!fe(n))return p||(o.value=n),!0}const l=r?Number(s)e,rs=e=>Reflect.getPrototypeOf(e);function Wo(e,t,s){return function(...n){const i=this.__v_raw,o=K(i),r=wt(o),l=e==="entries"||e===Symbol.iterator&&r,a=e==="keys"&&r,p=i[e](...n),u=s?Js:t?Et:ke;return!t&&ae(o,"iterate",a?qs:gt),de(Object.create(p),{next(){const{value:h,done:T}=p.next();return T?{value:h,done:T}:{value:l?[u(h[0]),u(h[1])]:u(h),done:T}}})}}function ls(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function Go(e,t){const s={get(i){const o=this.__v_raw,r=K(o),l=K(i);e||(Ne(i,l)&&ae(r,"get",i),ae(r,"get",l));const{has:a}=rs(r),p=t?Js:e?Et:ke;if(a.call(r,i))return p(o.get(i));if(a.call(r,l))return p(o.get(l));o!==r&&o.get(i)},get size(){const i=this.__v_raw;return!e&&ae(K(i),"iterate",gt),i.size},has(i){const o=this.__v_raw,r=K(o),l=K(i);return e||(Ne(i,l)&&ae(r,"has",i),ae(r,"has",l)),i===l?o.has(i):o.has(i)||o.has(l)},forEach(i,o){const r=this,l=r.__v_raw,a=K(l),p=t?Js:e?Et:ke;return!e&&ae(a,"iterate",gt),l.forEach((u,h)=>i.call(o,p(u),p(h),r))}};return de(s,e?{add:ls("add"),set:ls("set"),delete:ls("delete"),clear:ls("clear")}:{add(i){const o=K(this),r=rs(o),l=K(i),a=!t&&!we(i)&&!Xe(i)?l:i;return r.has.call(o,a)||Ne(i,a)&&r.has.call(o,i)||Ne(l,a)&&r.has.call(o,l)||(o.add(a),qe(o,"add",a,a)),this},set(i,o){!t&&!we(o)&&!Xe(o)&&(o=K(o));const r=K(this),{has:l,get:a}=rs(r);let p=l.call(r,i);p||(i=K(i),p=l.call(r,i));const u=a.call(r,i);return r.set(i,o),p?Ne(o,u)&&qe(r,"set",i,o):qe(r,"add",i,o),this},delete(i){const o=K(this),{has:r,get:l}=rs(o);let a=r.call(o,i);a||(i=K(i),a=r.call(o,i)),l&&l.call(o,i);const p=o.delete(i);return a&&qe(o,"delete",i,void 0),p},clear(){const i=K(this),o=i.size!==0,r=i.clear();return o&&qe(i,"clear",void 0,void 0),r}}),["keys","values","entries",Symbol.iterator].forEach(i=>{s[i]=Wo(i,e,t)}),s}function hn(e,t){const s=Go(e,t);return(n,i,o)=>i==="__v_isReactive"?!e:i==="__v_isReadonly"?e:i==="__v_raw"?n:Reflect.get(W(s,i)&&i in n?s:n,i,o)}const zo={get:hn(!1,!1)},qo={get:hn(!1,!0)},Jo={get:hn(!0,!1)};const Ai=new WeakMap,Oi=new WeakMap,Mi=new WeakMap,Yo=new WeakMap;function Xo(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function qt(e){return Xe(e)?e:gn(e,!1,jo,zo,Ai)}function Zo(e){return gn(e,!1,Ko,qo,Oi)}function Ys(e){return gn(e,!0,Bo,Jo,Mi)}function gn(e,t,s,n,i){if(!z(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const o=i.get(e);if(o)return o;const r=Xo(So(e));if(r===0)return e;const l=new Proxy(e,r===2?n:s);return i.set(e,l),l}function mt(e){return Xe(e)?mt(e.__v_raw):!!(e&&e.__v_isReactive)}function Xe(e){return!!(e&&e.__v_isReadonly)}function we(e){return!!(e&&e.__v_isShallow)}function mn(e){return e?!!e.__v_raw:!1}function K(e){const t=e&&e.__v_raw;return t?K(t):e}function Qo(e){return!W(e,"__v_skip")&&Object.isExtensible(e)&&gi(e,"__v_skip",!0),e}const ke=e=>z(e)?qt(e):e,Et=e=>z(e)?Ys(e):e;function fe(e){return e?e.__v_isRef===!0:!1}function j(e){return er(e,!1)}function er(e,t){return fe(e)?e:new tr(e,t)}class tr{constructor(t,s){this.dep=new pn,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:K(t),this._value=s?t:ke(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,n=this.__v_isShallow||we(t)||Xe(t);t=n?t:K(t),Ne(t,s)&&(this._rawValue=t,this._value=n?t:ke(t),this.dep.trigger())}}function se(e){return fe(e)?e.value:e}const sr={get:(e,t,s)=>t==="__v_raw"?e:se(Reflect.get(e,t,s)),set:(e,t,s,n)=>{const i=e[t];return fe(i)&&!fe(s)?(i.value=s,!0):Reflect.set(e,t,s,n)}};function Ri(e){return mt(e)?e:new Proxy(e,sr)}class nr{constructor(t,s,n){this.fn=t,this.setter=s,this._value=void 0,this.dep=new pn(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Gt-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&X!==this)return xi(this,!0),!0}get value(){const t=this.dep.track();return Ti(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function ir(e,t,s=!1){let n,i;return H(e)?n=e:(n=e.get,i=e.set),new nr(n,i,s)}const as={},ps=new WeakMap;let dt;function or(e,t=!1,s=dt){if(s){let n=ps.get(s);n||ps.set(s,n=[]),n.push(e)}}function rr(e,t,s=J){const{immediate:n,deep:i,once:o,scheduler:r,augmentJob:l,call:a}=s,p=R=>i?R:we(R)||i===!1||i===0?Je(R,1):Je(R);let u,h,T,$,F=!1,y=!1;if(fe(e)?(h=()=>e.value,F=we(e)):mt(e)?(h=()=>p(e),F=!0):U(e)?(y=!0,F=e.some(R=>mt(R)||we(R)),h=()=>e.map(R=>{if(fe(R))return R.value;if(mt(R))return p(R);if(H(R))return a?a(R,2):R()})):H(e)?t?h=a?()=>a(e,2):e:h=()=>{if(T){Be();try{T()}finally{Ke()}}const R=dt;dt=u;try{return a?a(e,3,[$]):e($)}finally{dt=R}}:h=He,t&&i){const R=h,ie=i===!0?1/0:i;h=()=>Je(R(),ie)}const S=Io(),x=()=>{u.stop(),S&&S.active&&rn(S.effects,u)};if(o&&t){const R=t;t=(...ie)=>{const $e=R(...ie);return x(),$e}}let C=y?new Array(e.length).fill(as):as;const M=R=>{if(!(!(u.flags&1)||!u.dirty&&!R))if(t){const ie=u.run();if(R||i||F||(y?ie.some(($e,Ae)=>Ne($e,C[Ae])):Ne(ie,C))){T&&T();const $e=dt;dt=u;try{const Ae=[ie,C===as?void 0:y&&C[0]===as?[]:C,$];C=ie,a?a(t,3,Ae):t(...Ae)}finally{dt=$e}}}else u.run()};return l&&l(M),u=new _i(h),u.scheduler=r?()=>r(M,!1):M,$=R=>or(R,!1,u),T=u.onStop=()=>{const R=ps.get(u);if(R){if(a)a(R,4);else for(const ie of R)ie();ps.delete(u)}},t?n?M(!0):C=u.run():r?r(M.bind(null,!0),!0):u.run(),x.pause=u.pause.bind(u),x.resume=u.resume.bind(u),x.stop=x,x}function Je(e,t=1/0,s){if(t<=0||!z(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,fe(e))Je(e.value,t,s);else if(U(e))for(let n=0;n{Je(n,t,s)});else if(pi(e)){for(const n in e)Je(e[n],t,s);for(const n of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,n)&&Je(e[n],t,s)}return e}/** +* @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>>1,i=ge[n],o=Jt(i);o=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(;sJt(s)-Jt(n));if(St.length=0,st){st.push(...t);return}for(st=t,yt=0;yte.id==null?e.flags&2?-1:1/0:e.id;function Ui(e){try{for(Fe=0;Fe{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;i1)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;ie.__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){const C=y?$(a)?h[a]:u[a]:F()||!e.k?a.value:u[e.k];if(i)U(C)&&rn(C,o);else if(U(C))C.includes(o)||C.push(o);else if(y)u[a]=[o],$(a)&&(h[a]=u[a]);else{const M=[o];F(a,e.k)&&(a.value=M),e.k&&(u[e.k]=M)}}else y?(u[a]=r,$(a)&&(h[a]=r)):S&&(F(a,e.k)&&(a.value=r),e.k&&(u[e.k]=r))};if(r){const C=()=>{x(),ms.delete(e)};C.id=-1,ms.set(e,C),be(C,s)}else Dn(e),x()}}}function Dn(e){const t=ms.get(e);t&&(t.flags|=8,ms.delete(e))}Cs().requestIdleCallback;Cs().cancelIdleCallback;const Bt=e=>!!e.type.__asyncLoader,Bi=e=>e.type.__isKeepAlive;function br(e,t){Ki(e,"a",t)}function vr(e,t){Ki(e,"da",t)}function Ki(e,t,s=me){const n=e.__wdc||(e.__wdc=()=>{let i=s;for(;i;){if(i.isDeactivated)return;i=i.parent}return e()});if(Ps(t,n,s),s){let i=s.parent;for(;i&&i.parent;)Bi(i.parent.vnode)&&_r(n,t,s,i),i=i.parent}}function _r(e,t,s,n){const i=Ps(t,e,n,!0);_n(()=>{rn(n[t],i)},s)}function Ps(e,t,s=me,n=!1){if(s){const i=s[e]||(s[e]=[]),o=t.__weh||(t.__weh=(...r)=>{Be();const l=es(s),a=Pe(t,s,e,r);return l(),Ke(),a});return n?i.unshift(o):i.push(o),o}}const Qe=e=>(t,s=me)=>{(!Xt||e==="sp")&&Ps(e,(...n)=>t(...n),s)},yr=Qe("bm"),vt=Qe("m"),xr=Qe("bu"),wr=Qe("u"),Sr=Qe("bum"),_n=Qe("um"),Tr=Qe("sp"),Cr=Qe("rtg"),Er=Qe("rtc");function kr(e,t=me){Ps("ec",e,t)}const Pr=Symbol.for("v-ndc");function Te(e,t,s,n){let i;const o=s,r=U(e);if(r||ee(e)){const l=r&&mt(e);let a=!1,p=!1;l&&(a=!we(e),p=Xe(e),e=Es(e)),i=new Array(e.length);for(let u=0,h=e.length;ut(l,a,void 0,o));else{const l=Object.keys(e);i=new Array(l.length);for(let a=0,p=l.length;ae?fo(e)?Os(e):Xs(e.parent):null,Kt=de(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Xs(e.parent),$root:e=>Xs(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Gi(e),$forceUpdate:e=>e.f||(e.f=()=>{bn(e.update)}),$nextTick:e=>e.n||(e.n=Di.bind(e.proxy)),$watch:e=>hr.bind(e)}),Vs=(e,t)=>e!==J&&!e.__isScriptSetup&&W(e,t),$r={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:n,data:i,props:o,accessCache:r,type:l,appContext:a}=e;if(t[0]!=="$"){const T=r[t];if(T!==void 0)switch(T){case 1:return n[t];case 2:return i[t];case 4:return s[t];case 3:return o[t]}else{if(Vs(n,t))return r[t]=1,n[t];if(i!==J&&W(i,t))return r[t]=2,i[t];if(W(o,t))return r[t]=3,o[t];if(s!==J&&W(s,t))return r[t]=4,s[t];Zs&&(r[t]=0)}}const p=Kt[t];let u,h;if(p)return t==="$attrs"&&ae(e.attrs,"get",""),p(e);if((u=l.__cssModules)&&(u=u[t]))return u;if(s!==J&&W(s,t))return r[t]=4,s[t];if(h=a.config.globalProperties,W(h,t))return h[t]},set({_:e},t,s){const{data:n,setupState:i,ctx:o}=e;return Vs(i,t)?(i[t]=s,!0):n!==J&&W(n,t)?(n[t]=s,!0):W(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(o[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:n,appContext:i,props:o,type:r}},l){let a;return!!(s[l]||e!==J&&l[0]!=="$"&&W(e,l)||Vs(t,l)||W(o,l)||W(n,l)||W(Kt,l)||W(i.config.globalProperties,l)||(a=r.__cssModules)&&a[l])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:W(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}};function Fn(e){return U(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}let Zs=!0;function Ar(e){const t=Gi(e),s=e.proxy,n=e.ctx;Zs=!1,t.beforeCreate&&Ln(t.beforeCreate,e,"bc");const{data:i,computed:o,methods:r,watch:l,provide:a,inject:p,created:u,beforeMount:h,mounted:T,beforeUpdate:$,updated:F,activated:y,deactivated:S,beforeDestroy:x,beforeUnmount:C,destroyed:M,unmounted:R,render:ie,renderTracked:$e,renderTriggered:Ae,errorCaptured:et,serverPrefetch:ts,expose:lt,inheritAttrs:Ot,components:ss,directives:ns,filters:Ms}=t;if(p&&Or(p,n,null),r)for(const Z in r){const Y=r[Z];H(Y)&&(n[Z]=Y.bind(s))}if(i){const Z=i.call(s,s);z(Z)&&(e.data=qt(Z))}if(Zs=!0,o)for(const Z in o){const Y=o[Z],at=H(Y)?Y.bind(s,s):H(Y.get)?Y.get.bind(s,s):He,is=!H(Y)&&H(Y.set)?Y.set.bind(s):He,ct=bt({get:at,set:is});Object.defineProperty(n,Z,{enumerable:!0,configurable:!0,get:()=>ct.value,set:Oe=>ct.value=Oe})}if(l)for(const Z in l)Wi(l[Z],n,s,Z);if(a){const Z=H(a)?a.call(s):a;Reflect.ownKeys(Z).forEach(Y=>{fr(Y,Z[Y])})}u&&Ln(u,e,"c");function pe(Z,Y){U(Y)?Y.forEach(at=>Z(at.bind(s))):Y&&Z(Y.bind(s))}if(pe(yr,h),pe(vt,T),pe(xr,$),pe(wr,F),pe(br,y),pe(vr,S),pe(kr,et),pe(Er,$e),pe(Cr,Ae),pe(Sr,C),pe(_n,R),pe(Tr,ts),U(lt))if(lt.length){const Z=e.exposed||(e.exposed={});lt.forEach(Y=>{Object.defineProperty(Z,Y,{get:()=>s[Y],set:at=>s[Y]=at,enumerable:!0})})}else e.exposed||(e.exposed={});ie&&e.render===He&&(e.render=ie),Ot!=null&&(e.inheritAttrs=Ot),ss&&(e.components=ss),ns&&(e.directives=ns),ts&&ji(e)}function Or(e,t,s=He){U(e)&&(e=Qs(e));for(const n in e){const i=e[n];let o;z(i)?"default"in i?o=fs(i.from||n,i.default,!0):o=fs(i.from||n):o=fs(i),fe(o)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>o.value,set:r=>o.value=r}):t[n]=o}}function Ln(e,t,s){Pe(U(e)?e.map(n=>n.bind(t.proxy)):e.bind(t.proxy),t,s)}function Wi(e,t,s,n){let i=n.includes(".")?Vi(s,n):()=>s[n];if(ee(e)){const o=t[e];H(o)&&Ns(i,o)}else if(H(e))Ns(i,e.bind(s));else if(z(e))if(U(e))e.forEach(o=>Wi(o,t,s,n));else{const o=H(e.handler)?e.handler.bind(s):t[e.handler];H(o)&&Ns(i,o,e)}}function Gi(e){const t=e.type,{mixins:s,extends:n}=t,{mixins:i,optionsCache:o,config:{optionMergeStrategies:r}}=e.appContext,l=o.get(t);let a;return l?a=l:!i.length&&!s&&!n?a=t:(a={},i.length&&i.forEach(p=>bs(a,p,r,!0)),bs(a,t,r)),z(t)&&o.set(t,a),a}function bs(e,t,s,n=!1){const{mixins:i,extends:o}=t;o&&bs(e,o,s,!0),i&&i.forEach(r=>bs(e,r,s,!0));for(const r in t)if(!(n&&r==="expose")){const l=Mr[r]||s&&s[r];e[r]=l?l(e[r],t[r]):t[r]}return e}const Mr={data:Un,props:Nn,emits:Nn,methods:Lt,computed:Lt,beforeCreate:he,created:he,beforeMount:he,mounted:he,beforeUpdate:he,updated:he,beforeDestroy:he,beforeUnmount:he,destroyed:he,unmounted:he,activated:he,deactivated:he,errorCaptured:he,serverPrefetch:he,components:Lt,directives:Lt,watch:Ir,provide:Un,inject:Rr};function Un(e,t){return t?e?function(){return de(H(e)?e.call(this,this):e,H(t)?t.call(this,this):t)}:t:e}function Rr(e,t){return Lt(Qs(e),Qs(t))}function Qs(e){if(U(e)){const t={};for(let s=0;st==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${Ce(t)}Modifiers`]||e[`${rt(t)}Modifiers`];function Ur(e,t,...s){if(e.isUnmounted)return;const n=e.vnode.props||J;let i=s;const o=t.startsWith("update:"),r=o&&Lr(n,t.slice(7));r&&(r.trim&&(i=s.map(u=>ee(u)?u.trim():u)),r.number&&(i=s.map(Ts)));let l,a=n[l=Is(t)]||n[l=Is(Ce(t))];!a&&o&&(a=n[l=Is(rt(t))]),a&&Pe(a,e,6,i);const p=n[l+"Once"];if(p){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,Pe(p,e,6,i)}}const Nr=new WeakMap;function qi(e,t,s=!1){const n=s?Nr:t.emitsCache,i=n.get(e);if(i!==void 0)return i;const o=e.emits;let r={},l=!1;if(!H(e)){const a=p=>{const u=qi(p,t,!0);u&&(l=!0,de(r,u))};!s&&t.mixins.length&&t.mixins.forEach(a),e.extends&&a(e.extends),e.mixins&&e.mixins.forEach(a)}return!o&&!l?(z(e)&&n.set(e,null),null):(U(o)?o.forEach(a=>r[a]=null):de(r,o),z(e)&&n.set(e,r),r)}function $s(e,t){return!e||!xs(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),W(e,t[0].toLowerCase()+t.slice(1))||W(e,rt(t))||W(e,t))}function Hn(e){const{type:t,vnode:s,proxy:n,withProxy:i,propsOptions:[o],slots:r,attrs:l,emit:a,render:p,renderCache:u,props:h,data:T,setupState:$,ctx:F,inheritAttrs:y}=e,S=gs(e);let x,C;try{if(s.shapeFlag&4){const R=i||n,ie=R;x=Ue(p.call(ie,R,u,h,$,T,F)),C=l}else{const R=t;x=Ue(R.length>1?R(h,{attrs:l,slots:r,emit:a}):R(h,null)),C=t.props?l:Hr(l)}}catch(R){Wt.length=0,ks(R,e,1),x=le(it)}let M=x;if(C&&y!==!1){const R=Object.keys(C),{shapeFlag:ie}=M;R.length&&ie&7&&(o&&R.some(ws)&&(C=Vr(C,o)),M=kt(M,C,!1,!0))}return s.dirs&&(M=kt(M,null,!1,!0),M.dirs=M.dirs?M.dirs.concat(s.dirs):s.dirs),s.transition&&vn(M,s.transition),x=M,gs(S),x}const Hr=e=>{let t;for(const s in e)(s==="class"||s==="style"||xs(s))&&((t||(t={}))[s]=e[s]);return t},Vr=(e,t)=>{const s={};for(const n in e)(!ws(n)||!(n.slice(9)in t))&&(s[n]=e[n]);return s};function jr(e,t,s){const{props:n,children:i,component:o}=e,{props:r,children:l,patchFlag:a}=t,p=o.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&a>=0){if(a&1024)return!0;if(a&16)return n?Vn(n,r,p):!!r;if(a&8){const u=t.dynamicProps;for(let h=0;hObject.create(Yi),Zi=e=>Object.getPrototypeOf(e)===Yi;function Kr(e,t,s,n=!1){const i={},o=Xi();e.propsDefaults=Object.create(null),Qi(e,t,i,o);for(const r in e.propsOptions[0])r in i||(i[r]=void 0);s?e.props=n?i:Zo(i):e.type.props?e.props=i:e.props=o,e.attrs=o}function Wr(e,t,s,n){const{props:i,attrs:o,vnode:{patchFlag:r}}=e,l=K(i),[a]=e.propsOptions;let p=!1;if((n||r>0)&&!(r&16)){if(r&8){const u=e.vnode.dynamicProps;for(let h=0;h{a=!0;const[T,$]=eo(h,t,!0);de(r,T),$&&l.push(...$)};!s&&t.mixins.length&&t.mixins.forEach(u),e.extends&&u(e.extends),e.mixins&&e.mixins.forEach(u)}if(!o&&!a)return z(e)&&n.set(e,xt),xt;if(U(o))for(let u=0;ue==="_"||e==="_ctx"||e==="$stable",xn=e=>U(e)?e.map(Ue):[Ue(e)],zr=(e,t,s)=>{if(t._n)return t;const n=ur((...i)=>xn(t(...i)),s);return n._c=!1,n},to=(e,t,s)=>{const n=e._ctx;for(const i in e){if(yn(i))continue;const o=e[i];if(H(o))t[i]=zr(i,o,n);else if(o!=null){const r=xn(o);t[i]=()=>r}}},so=(e,t)=>{const s=xn(t);e.slots.default=()=>s},no=(e,t,s)=>{for(const n in t)(s||!yn(n))&&(e[n]=t[n])},qr=(e,t,s)=>{const n=e.slots=Xi();if(e.vnode.shapeFlag&32){const i=t._;i?(no(n,t,s),s&&gi(n,"_",i,!0)):to(t,n)}else t&&so(e,t)},Jr=(e,t,s)=>{const{vnode:n,slots:i}=e;let o=!0,r=J;if(n.shapeFlag&32){const l=t._;l?s&&l===1?o=!1:no(i,t,s):(o=!t.$stable,to(t,i)),r=t}else t&&(so(e,t),r={default:1});if(o)for(const l in i)!yn(l)&&r[l]==null&&delete i[l]},be=el;function Yr(e){return Xr(e)}function Xr(e,t){const s=Cs();s.__VUE__=!0;const{insert:n,remove:i,patchProp:o,createElement:r,createText:l,createComment:a,setText:p,setElementText:u,parentNode:h,nextSibling:T,setScopeId:$=He,insertStaticContent:F}=e,y=(c,f,g,_=null,v=null,m=null,A=void 0,P=null,k=!!f.dynamicChildren)=>{if(c===f)return;c&&!Ft(c,f)&&(_=os(c),Oe(c,v,m,!0),c=null),f.patchFlag===-2&&(k=!1,f.dynamicChildren=null);const{type:b,ref:L,shapeFlag:O}=f;switch(b){case As:S(c,f,g,_);break;case it:x(c,f,g,_);break;case Bs:c==null&&C(f,g,_,A);break;case Q:ss(c,f,g,_,v,m,A,P,k);break;default:O&1?ie(c,f,g,_,v,m,A,P,k):O&6?ns(c,f,g,_,v,m,A,P,k):(O&64||O&128)&&b.process(c,f,g,_,v,m,A,P,k,Rt)}L!=null&&v?jt(L,c&&c.ref,m,f||c,!f):L==null&&c&&c.ref!=null&&jt(c.ref,null,m,c,!0)},S=(c,f,g,_)=>{if(c==null)n(f.el=l(f.children),g,_);else{const v=f.el=c.el;f.children!==c.children&&p(v,f.children)}},x=(c,f,g,_)=>{c==null?n(f.el=a(f.children||""),g,_):f.el=c.el},C=(c,f,g,_)=>{[c.el,c.anchor]=F(c.children,f,g,_,c.el,c.anchor)},M=({el:c,anchor:f},g,_)=>{let v;for(;c&&c!==f;)v=T(c),n(c,g,_),c=v;n(f,g,_)},R=({el:c,anchor:f})=>{let g;for(;c&&c!==f;)g=T(c),i(c),c=g;i(f)},ie=(c,f,g,_,v,m,A,P,k)=>{if(f.type==="svg"?A="svg":f.type==="math"&&(A="mathml"),c==null)$e(f,g,_,v,m,A,P,k);else{const b=c.el&&c.el._isVueCE?c.el:null;try{b&&b._beginPatch(),ts(c,f,v,m,A,P,k)}finally{b&&b._endPatch()}}},$e=(c,f,g,_,v,m,A,P)=>{let k,b;const{props:L,shapeFlag:O,transition:I,dirs:N}=c;if(k=c.el=r(c.type,m,L&&L.is,L),O&8?u(k,c.children):O&16&&et(c.children,k,null,_,v,js(c,m),A,P),N&&ut(c,null,_,"created"),Ae(k,c,c.scopeId,A,_),L){for(const q in L)q!=="value"&&!Nt(q)&&o(k,q,null,L[q],m,_);"value"in L&&o(k,"value",null,L.value,m),(b=L.onVnodeBeforeMount)&&De(b,_,c)}N&&ut(c,null,_,"beforeMount");const B=Zr(v,I);B&&I.beforeEnter(k),n(k,f,g),((b=L&&L.onVnodeMounted)||B||N)&&be(()=>{try{b&&De(b,_,c),B&&I.enter(k),N&&ut(c,null,_,"mounted")}finally{}},v)},Ae=(c,f,g,_,v)=>{if(g&&$(c,g),_)for(let m=0;m<_.length;m++)$(c,_[m]);if(v){let m=v.subTree;if(f===m||lo(m.type)&&(m.ssContent===f||m.ssFallback===f)){const A=v.vnode;Ae(c,A,A.scopeId,A.slotScopeIds,v.parent)}}},et=(c,f,g,_,v,m,A,P,k=0)=>{for(let b=k;b{const P=f.el=c.el;let{patchFlag:k,dynamicChildren:b,dirs:L}=f;k|=c.patchFlag&16;const O=c.props||J,I=f.props||J;let N;if(g&&ft(g,!1),(N=I.onVnodeBeforeUpdate)&&De(N,g,f,c),L&&ut(f,c,g,"beforeUpdate"),g&&ft(g,!0),b&&(!c.dynamicChildren||c.dynamicChildren.length!==b.length)&&(k=0,A=!1,b=null),(O.innerHTML&&I.innerHTML==null||O.textContent&&I.textContent==null)&&u(P,""),b?lt(c.dynamicChildren,b,P,g,_,js(f,v),m):A||Y(c,f,P,null,g,_,js(f,v),m,!1),k>0){if(k&16)Ot(P,O,I,g,v);else if(k&2&&O.class!==I.class&&o(P,"class",null,I.class,v),k&4&&o(P,"style",O.style,I.style,v),k&8){const B=f.dynamicProps;for(let q=0;q{N&&De(N,g,f,c),L&&ut(f,c,g,"updated")},_)},lt=(c,f,g,_,v,m,A)=>{for(let P=0;P{if(f!==g){if(f!==J)for(const m in f)!Nt(m)&&!(m in g)&&o(c,m,f[m],null,v,_);for(const m in g){if(Nt(m))continue;const A=g[m],P=f[m];A!==P&&m!=="value"&&o(c,m,P,A,v,_)}"value"in g&&o(c,"value",f.value,g.value,v)}},ss=(c,f,g,_,v,m,A,P,k)=>{const b=f.el=c?c.el:l(""),L=f.anchor=c?c.anchor:l("");let{patchFlag:O,dynamicChildren:I,slotScopeIds:N}=f;N&&(P=P?P.concat(N):N),c==null?(n(b,g,_),n(L,g,_),et(f.children||[],g,L,v,m,A,P,k)):O>0&&O&64&&I&&c.dynamicChildren&&c.dynamicChildren.length===I.length?(lt(c.dynamicChildren,I,g,v,m,A,P),(f.key!=null||v&&f===v.subTree)&&io(c,f,!0)):Y(c,f,g,L,v,m,A,P,k)},ns=(c,f,g,_,v,m,A,P,k)=>{f.slotScopeIds=P,c==null?f.shapeFlag&512?v.ctx.activate(f,g,_,A,k):Ms(f,g,_,v,m,A,k):Sn(c,f,k)},Ms=(c,f,g,_,v,m,A)=>{const P=c.component=ll(c,_,v);if(Bi(c)&&(P.ctx.renderer=Rt),cl(P,!1,A),P.asyncDep){if(v&&v.registerDep(P,pe,A),!c.el){const k=P.subTree=le(it);x(null,k,f,g),c.placeholder=k.el}}else pe(P,c,f,g,v,m,A)},Sn=(c,f,g)=>{const _=f.component=c.component;if(jr(c,f,g))if(_.asyncDep&&!_.asyncResolved){Z(_,f,g);return}else _.next=f,_.update();else f.el=c.el,_.vnode=f},pe=(c,f,g,_,v,m,A)=>{const P=()=>{if(c.isMounted){let{next:O,bu:I,u:N,parent:B,vnode:q}=c;{const Re=oo(c);if(Re){O&&(O.el=q.el,Z(c,O,A)),Re.asyncDep.then(()=>{be(()=>{c.isUnmounted||b()},v)});return}}let G=O,te;ft(c,!1),O?(O.el=q.el,Z(c,O,A)):O=q,I&&us(I),(te=O.props&&O.props.onVnodeBeforeUpdate)&&De(te,B,O,q),ft(c,!0);const oe=Hn(c),Me=c.subTree;c.subTree=oe,y(Me,oe,h(Me.el),os(Me),c,v,m),O.el=oe.el,G===null&&Br(c,oe.el),N&&be(N,v),(te=O.props&&O.props.onVnodeUpdated)&&be(()=>De(te,B,O,q),v)}else{let O;const{el:I,props:N}=f,{bm:B,m:q,parent:G,root:te,type:oe}=c,Me=Bt(f);ft(c,!1),B&&us(B),!Me&&(O=N&&N.onVnodeBeforeMount)&&De(O,G,f),ft(c,!0);{te.ce&&te.ce._hasShadowRoot()&&te.ce._injectChildStyle(oe,c.parent?c.parent.type:void 0);const Re=c.subTree=Hn(c);y(null,Re,g,_,c,v,m),f.el=Re.el}if(q&&be(q,v),!Me&&(O=N&&N.onVnodeMounted)){const Re=f;be(()=>De(O,G,Re),v)}(f.shapeFlag&256||G&&Bt(G.vnode)&&G.vnode.shapeFlag&256)&&c.a&&be(c.a,v),c.isMounted=!0,f=g=_=null}};c.scope.on();const k=c.effect=new _i(P);c.scope.off();const b=c.update=k.run.bind(k),L=c.job=k.runIfDirty.bind(k);L.i=c,L.id=c.uid,k.scheduler=()=>bn(L),ft(c,!0),b()},Z=(c,f,g)=>{f.component=c;const _=c.vnode.props;c.vnode=f,c.next=null,Wr(c,f.props,_,g),Jr(c,f.children,g),Be(),Rn(c),Ke()},Y=(c,f,g,_,v,m,A,P,k=!1)=>{const b=c&&c.children,L=c?c.shapeFlag:0,O=f.children,{patchFlag:I,shapeFlag:N}=f;if(I>0){if(I&128){is(b,O,g,_,v,m,A,P,k);return}else if(I&256){at(b,O,g,_,v,m,A,P,k);return}}N&8?(L&16&&Mt(b,v,m),O!==b&&u(g,O)):L&16?N&16?is(b,O,g,_,v,m,A,P,k):Mt(b,v,m,!0):(L&8&&u(g,""),N&16&&et(O,g,_,v,m,A,P,k))},at=(c,f,g,_,v,m,A,P,k)=>{c=c||xt,f=f||xt;const b=c.length,L=f.length,O=Math.min(b,L);let I;for(I=0;IL?Mt(c,v,m,!0,!1,O):et(f,g,_,v,m,A,P,k,O)},is=(c,f,g,_,v,m,A,P,k)=>{let b=0;const L=f.length;let O=c.length-1,I=L-1;for(;b<=O&&b<=I;){const N=c[b],B=f[b]=k?ze(f[b]):Ue(f[b]);if(Ft(N,B))y(N,B,g,null,v,m,A,P,k);else break;b++}for(;b<=O&&b<=I;){const N=c[O],B=f[I]=k?ze(f[I]):Ue(f[I]);if(Ft(N,B))y(N,B,g,null,v,m,A,P,k);else break;O--,I--}if(b>O){if(b<=I){const N=I+1,B=NI)for(;b<=O;)Oe(c[b],v,m,!0),b++;else{const N=b,B=b,q=new Map;for(b=B;b<=I;b++){const _e=f[b]=k?ze(f[b]):Ue(f[b]);_e.key!=null&&q.set(_e.key,b)}let G,te=0;const oe=I-B+1;let Me=!1,Re=0;const It=new Array(oe);for(b=0;b=oe){Oe(_e,v,m,!0);continue}let Ie;if(_e.key!=null)Ie=q.get(_e.key);else for(G=B;G<=I;G++)if(It[G-B]===0&&Ft(_e,f[G])){Ie=G;break}Ie===void 0?Oe(_e,v,m,!0):(It[Ie-B]=b+1,Ie>=Re?Re=Ie:Me=!0,y(_e,f[Ie],g,null,v,m,A,P,k),te++)}const En=Me?Qr(It):xt;for(G=En.length-1,b=oe-1;b>=0;b--){const _e=B+b,Ie=f[_e],kn=f[_e+1],Pn=_e+1{const{el:m,type:A,transition:P,children:k,shapeFlag:b}=c;if(b&6){ct(c.component.subTree,f,g,_);return}if(b&128){c.suspense.move(f,g,_);return}if(b&64){A.move(c,f,g,Rt);return}if(A===Q){n(m,f,g);for(let O=0;OP.enter(m),v));else{const{leave:O,delayLeave:I,afterLeave:N}=P,B=()=>{c.ctx.isUnmounted?i(m):n(m,f,g)},q=()=>{const G=m._isLeaving||!!m[Hs];m._isLeaving&&m[Hs](!0),P.persisted&&!G?B():O(m,()=>{B(),N&&N()})};I?I(m,B,q):q()}else n(m,f,g)},Oe=(c,f,g,_=!1,v=!1)=>{const{type:m,props:A,ref:P,children:k,dynamicChildren:b,shapeFlag:L,patchFlag:O,dirs:I,cacheIndex:N,memo:B}=c;if(O===-2&&(v=!1),P!=null&&(Be(),jt(P,null,g,c,!0),Ke()),N!=null&&(f.renderCache[N]=void 0),L&256){f.ctx.deactivate(c);return}const q=L&1&&I,G=!Bt(c);let te;if(G&&(te=A&&A.onVnodeBeforeUnmount)&&De(te,f,c),L&6)xo(c.component,g,_);else{if(L&128){c.suspense.unmount(g,_);return}q&&ut(c,null,f,"beforeUnmount"),L&64?c.type.remove(c,f,g,Rt,_):b&&!b.hasOnce&&(m!==Q||O>0&&O&64)?Mt(b,f,g,!1,!0):(m===Q&&O&384||!v&&L&16)&&Mt(k,f,g),_&&Tn(c)}const oe=B!=null&&N==null;(G&&(te=A&&A.onVnodeUnmounted)||q||oe)&&be(()=>{te&&De(te,f,c),q&&ut(c,null,f,"unmounted"),oe&&(c.el=null)},g)},Tn=c=>{const{type:f,el:g,anchor:_,transition:v}=c;if(f===Q){yo(g,_);return}if(f===Bs){R(c);return}const m=()=>{i(g),v&&!v.persisted&&v.afterLeave&&v.afterLeave()};if(c.shapeFlag&1&&v&&!v.persisted){const{leave:A,delayLeave:P}=v,k=()=>A(g,m);P?P(c.el,m,k):k()}else m()},yo=(c,f)=>{let g;for(;c!==f;)g=T(c),i(c),c=g;i(f)},xo=(c,f,g)=>{const{bum:_,scope:v,job:m,subTree:A,um:P,m:k,a:b}=c;Bn(k),Bn(b),_&&us(_),v.stop(),m&&(m.flags|=8,Oe(A,c,f,g)),P&&be(P,f),be(()=>{c.isUnmounted=!0},f)},Mt=(c,f,g,_=!1,v=!1,m=0)=>{for(let A=m;A{if(c.shapeFlag&6)return os(c.component.subTree);if(c.shapeFlag&128)return c.suspense.next();const f=T(c.anchor||c.el),g=f&&f[gr];return g?T(g):f};let Rs=!1;const Cn=(c,f,g)=>{let _;c==null?f._vnode&&(Oe(f._vnode,null,null,!0),_=f._vnode.component):y(f._vnode||null,c,f,null,null,null,g),f._vnode=c,Rs||(Rs=!0,Rn(_),Li(),Rs=!1)},Rt={p:y,um:Oe,m:ct,r:Tn,mt:Ms,mc:et,pc:Y,pbc:lt,n:os,o:e};return{render:Cn,hydrate:void 0,createApp:Fr(Cn)}}function js({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function ft({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Zr(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function io(e,t,s=!1){const n=e.children,i=t.children;if(U(n)&&U(i))for(let o=0;o>1,e[s[l]]0&&(t[n]=s[o-1]),s[o]=n)}}for(o=s.length,r=s[o-1];o-- >0;)s[o]=r,r=t[r];return s}function oo(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:oo(t)}function Bn(e){if(e)for(let t=0;te.__isSuspense;function el(e,t){t&&t.pendingBranch?U(e)?t.effects.push(...e):t.effects.push(e):cr(e)}const Q=Symbol.for("v-fgt"),As=Symbol.for("v-txt"),it=Symbol.for("v-cmt"),Bs=Symbol.for("v-stc"),Wt=[];let ye=null;function w(e=!1){Wt.push(ye=e?null:[])}function tl(){Wt.pop(),ye=Wt[Wt.length-1]||null}let Yt=1;function Kn(e,t=!1){Yt+=e,e<0&&ye&&t&&(ye.hasOnce=!0)}function ao(e){return e.dynamicChildren=Yt>0?ye||xt:null,tl(),Yt>0&&ye&&ye.push(e),e}function E(e,t,s,n,i,o){return ao(d(e,t,s,n,i,o,!0))}function pt(e,t,s,n,i){return ao(le(e,t,s,n,i,!0))}function co(e){return e?e.__v_isVNode===!0:!1}function Ft(e,t){return e.type===t.type&&e.key===t.key}const uo=({key:e})=>e??null,ds=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?ee(e)||fe(e)||H(e)?{i:xe,r:e,k:t,f:!!s}:e:null);function d(e,t=null,s=null,n=0,i=null,o=e===Q?0:1,r=!1,l=!1){const a={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&uo(t),ref:t&&ds(t),scopeId:Ni,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:n,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:xe};return l?(vs(a,s),o&128&&e.normalize(a)):s&&(a.shapeFlag|=ee(s)?8:16),Yt>0&&!r&&ye&&(a.patchFlag>0||o&6)&&a.patchFlag!==32&&ye.push(a),a}const le=sl;function sl(e,t=null,s=null,n=0,i=null,o=!1){if((!e||e===Pr)&&(e=it),co(e)){const l=kt(e,t,!0);return s&&vs(l,s),Yt>0&&!o&&ye&&(l.shapeFlag&6?ye[ye.indexOf(e)]=l:ye.push(l)),l.patchFlag=-2,l}if(pl(e)&&(e=e.__vccOpts),t){t=nl(t);let{class:l,style:a}=t;l&&!ee(l)&&(t.class=je(l)),z(a)&&(mn(a)&&!U(a)&&(a=de({},a)),t.style=an(a))}const r=ee(e)?1:lo(e)?128:mr(e)?64:z(e)?4:H(e)?2:0;return d(e,t,s,n,i,r,o,!0)}function nl(e){return e?mn(e)||Zi(e)?de({},e):e:null}function kt(e,t,s=!1,n=!1){const{props:i,ref:o,patchFlag:r,children:l,transition:a}=e,p=t?il(i||{},t):i,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:p,key:p&&uo(p),ref:t&&t.ref?s&&o?U(o)?o.concat(ds(t)):[o,ds(t)]:ds(t):o,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==Q?r===-1?16:r|16:r,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:a,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&kt(e.ssContent),ssFallback:e.ssFallback&&kt(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return a&&n&&vn(u,a.clone(u)),u}function Ze(e=" ",t=0){return le(As,null,e,t)}function V(e="",t=!1){return t?(w(),pt(it,null,e)):le(it,null,e)}function Ue(e){return e==null||typeof e=="boolean"?le(it):U(e)?le(Q,null,e.slice()):co(e)?ze(e):le(As,null,String(e))}function ze(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:kt(e)}function vs(e,t){let s=0;const{shapeFlag:n}=e;if(t==null)t=null;else if(U(t))s=16;else if(typeof t=="object")if(n&65){const i=t.default;i&&(i._c&&(i._d=!1),vs(e,i()),i._c&&(i._d=!0));return}else{s=32;const i=t._;!i&&!Zi(t)?t._ctx=xe:i===3&&xe&&(xe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(H(t)){if(n&65){vs(e,{default:t});return}t={default:t,_ctx:xe},s=32}else t=String(t),n&64?(s=16,t=[Ze(t)]):s=8;e.children=t,e.shapeFlag|=s}function il(...e){const t={};for(let s=0;sme||xe;let _s,tn;{const e=Cs(),t=(s,n)=>{let i;return(i=e[s])||(i=e[s]=[]),i.push(n),o=>{i.length>1?i.forEach(r=>r(o)):i[0](o)}};_s=t("__VUE_INSTANCE_SETTERS__",s=>me=s),tn=t("__VUE_SSR_SETTERS__",s=>Xt=s)}const es=e=>{const t=me;return _s(e),e.scope.on(),()=>{e.scope.off(),_s(t)}},Wn=()=>{me&&me.scope.off(),_s(null)};function fo(e){return e.vnode.shapeFlag&4}let Xt=!1;function cl(e,t=!1,s=!1){t&&tn(t);const{props:n,children:i}=e.vnode,o=fo(e);Kr(e,n,o,t),qr(e,i,s||t);const r=o?ul(e,t):void 0;return t&&tn(!1),r}function ul(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,$r);const{setup:n}=s;if(n){Be();const i=e.setupContext=n.length>1?dl(e):null,o=es(e),r=Qt(n,e,0,[e.props,i]),l=fi(r);if(Ke(),o(),(l||e.sp)&&!Bt(e)&&ji(e),l){if(r.then(Wn,Wn),t)return r.then(a=>{Gn(e,a)}).catch(a=>{ks(a,e,0)});e.asyncDep=r}else Gn(e,r)}else po(e)}function Gn(e,t,s){H(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:z(t)&&(e.setupState=Ri(t)),po(e)}function po(e,t,s){const n=e.type;e.render||(e.render=n.render||He);{const i=es(e);Be();try{Ar(e)}finally{Ke(),i()}}}const fl={get(e,t){return ae(e,"get",""),e[t]}};function dl(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,fl),slots:e.slots,emit:e.emit,expose:t}}function Os(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(Ri(Qo(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in Kt)return Kt[s](e)},has(t,s){return s in t||s in Kt}})):e.proxy}function pl(e){return H(e)&&"__vccOpts"in e}const bt=(e,t)=>ir(e,t,Xt),hl="3.5.39";/** +* @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"?`${e}`:n==="mathml"?`${e}`: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;iWs||(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;ae.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._falseValue=n),Qn(e,t,n,r))};function Dl(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&si(t)&&H(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const i=e.tagName;if(i==="IMG"||i==="VIDEO"||i==="CANVAS"||i==="SOURCE")return!1}return si(t)&&ee(s)?!1:t in e}function Fl(e,t){const s=e._def.props;if(!s)return!1;const n=Ce(t);return Array.isArray(s)?s.some(i=>Ce(i)===n):Object.keys(s).some(i=>Ce(i)===n)}const ot=e=>{const t=e.props["onUpdate:modelValue"]||!1;return U(t)?s=>us(t,s):t};function Ll(e){e.target.composing=!0}function ni(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Se=Symbol("_assign");function ii(e,t,s){return t&&(e=e.trim()),s&&(e=Ts(e)),e}const ve={created(e,{modifiers:{lazy:t,trim:s,number:n}},i){e[Se]=ot(i);const o=n||i.props&&i.props.type==="number";Ye(e,t?"change":"input",r=>{r.target.composing||e[Se](ii(e.value,s,o))}),(s||o)&&Ye(e,"change",()=>{e.value=ii(e.value,s,o)}),t||(Ye(e,"compositionstart",Ll),Ye(e,"compositionend",ni),Ye(e,"change",ni))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:s,modifiers:{lazy:n,trim:i,number:o}},r){if(e[Se]=ot(r),e.composing)return;const l=(o||e.type==="number")&&!/^0\d/.test(e.value)?Ts(e.value):e.value,a=t??"";if(l===a)return;const p=e.getRootNode();(p instanceof Document||p instanceof ShadowRoot)&&p.activeElement===e&&e.type!=="range"&&(n&&t===s||i&&e.value.trim()===a)||(e.value=a)}},Ul={deep:!0,created(e,t,s){e[Se]=ot(s),Ye(e,"change",()=>{const n=e._modelValue,i=Pt(e),o=e.checked,r=e[Se];if(U(n)){const l=cn(n,i),a=l!==-1;if(o&&!a)r(n.concat(i));else if(!o&&a){const p=[...n];p.splice(l,1),r(p)}}else if(At(n)){const l=new Set(n);o?l.add(i):l.delete(i),r(l)}else r(go(e,o))})},mounted:oi,beforeUpdate(e,t,s){e[Se]=ot(s),oi(e,t,s)}};function oi(e,{value:t,oldValue:s},n){e._modelValue=t;let i;if(U(t))i=cn(t,n.props.value)>-1;else if(At(t))i=t.has(n.props.value);else{if(t===s)return;i=nt(t,go(e,!0))}e.checked!==i&&(e.checked=i)}const Nl={created(e,{value:t},s){e.checked=nt(t,s.props.value),e[Se]=ot(s),Ye(e,"change",()=>{e[Se](Pt(e))})},beforeUpdate(e,{value:t,oldValue:s},n){e[Se]=ot(n),t!==s&&(e.checked=nt(t,n.props.value))}},ys={deep:!0,created(e,{value:t,modifiers:{number:s}},n){const i=At(t);Ye(e,"change",()=>{const o=Array.prototype.filter.call(e.options,r=>r.selected).map(r=>s?Ts(Pt(r)):Pt(r));e[Se](e.multiple?i?new Set(o):o:o[0]),e._assigning=!0,Di(()=>{e._assigning=!1})}),e[Se]=ot(n)},mounted(e,{value:t}){ri(e,t)},beforeUpdate(e,t,s){e[Se]=ot(s)},updated(e,{value:t}){e._assigning||ri(e,t)}};function ri(e,t){const s=e.multiple,n=U(t);if(!(s&&!n&&!At(t))){for(let i=0,o=e.options.length;iString(p)===String(l)):r.selected=cn(t,l)>-1}else r.selected=t.has(l);else if(nt(Pt(r),t)){e.selectedIndex!==i&&(e.selectedIndex=i);return}}!s&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function Pt(e){return"_value"in e?e._value:e.value}function go(e,t){const s=t?"_trueValue":"_falseValue";return s in e?e[s]:t}const Hl={created(e,t,s){cs(e,t,s,null,"created")},mounted(e,t,s){cs(e,t,s,null,"mounted")},beforeUpdate(e,t,s,n){cs(e,t,s,n,"beforeUpdate")},updated(e,t,s,n){cs(e,t,s,n,"updated")}};function Vl(e,t){switch(e){case"SELECT":return ys;case"TEXTAREA":return ve;default:switch(t){case"checkbox":return Ul;case"radio":return Nl;default:return ve}}}function cs(e,t,s,n,i){const r=Vl(e.tagName,s.props&&s.props.type)[i];r&&r(e,t,s,n)}const jl=["ctrl","shift","alt","meta"],Bl={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>jl.some(s=>e[`${s}Key`]&&!t.includes(s))},Kl=(e,t)=>{if(!e)return e;const s=e._withMods||(e._withMods={}),n=t.join(".");return s[n]||(s[n]=((i,...o)=>{for(let r=0;r{const s=e._withKeys||(e._withKeys={}),n=t.join(".");return s[n]||(s[n]=(i=>{if(!("key"in i))return;const o=rt(i.key);if(t.some(r=>r===o||Wl[r]===o))return e(i)}))},zl=de({patchProp:Il},bl);let li;function ql(){return li||(li=Yr(zl))}const Jl=((...e)=>{const t=ql().createApp(...e),{mount:s}=t;return t.mount=n=>{const i=Xl(n);if(!i)return;const o=t._component;!H(o)&&!o.render&&!o.template&&(o.template=i.innerHTML),i.nodeType===1&&(i.textContent="");const r=s(i,!1,Yl(i));return i instanceof Element&&(i.removeAttribute("v-cloak"),i.setAttribute("data-v-app","")),r},t});function Yl(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Xl(e){return ee(e)?document.querySelector(e):e}const mo="dh-panel-theme";function Zl(){var e;try{const t=localStorage.getItem(mo);if(t==="dark"||t==="light")return t}catch{}return(e=window.matchMedia)!=null&&e.call(window,"(prefers-color-scheme: dark)").matches?"dark":"light"}const Ct=j(Zl());function bo(e){Ct.value=e,document.documentElement.classList.toggle("dark",e==="dark");try{localStorage.setItem(mo,e)}catch{}}function ai(){bo(Ct.value==="dark"?"light":"dark")}bo(Ct.value);const nn="dh-panel-token";function Ql(){try{return localStorage.getItem(nn)||""}catch{return""}}const $t=j(Ql()),ce=j(null),ht=bt(()=>{var e;return((e=ce.value)==null?void 0:e.role)==="superadmin"}),ci=bt(()=>{var e,t;return((e=ce.value)==null?void 0:e.role)==="admin"||((t=ce.value)==null?void 0:t.role)==="superadmin"});function vo(e){$t.value=e;try{e?localStorage.setItem(nn,e):localStorage.removeItem(nn)}catch{}}class ea extends Error{constructor(t,s,n){super(t),this.status=s,this.body=n}}function ta(e,t){if(!e||typeof e!="object")return`HTTP ${t}`;if(e.error)return e.error;const s=Object.entries(e.data||{}).map(([n,i])=>`${n}: ${(i==null?void 0:i.message)||i}`).filter(Boolean);return s.length?s.join("; "):e.message||`HTTP ${t}`}async function ne(e,{method:t="GET",body:s,auth:n=!0}={}){const i={};s!==void 0&&(i["Content-Type"]="application/json"),n&&$t.value&&(i.Authorization=$t.value);const o=await fetch(e,{method:t,headers:i,body:s===void 0?void 0:JSON.stringify(s)}),r=await o.text();let l=null;try{l=r?JSON.parse(r):null}catch{l=null}if(!o.ok)throw o.status===401&&n&&wn(),new ea(ta(l,o.status),o.status,l);return l}async function sa(e,t){const s=await ne("/api/auth/login",{method:"POST",body:{email:e,password:t},auth:!1});return vo(s.token),await _o(),ce.value}async function _o(){return ce.value=await ne("/api/identity"),ce.value}function wn(){vo(""),ce.value=null}async function na(){if(!$t.value)return null;try{return await _o()}catch{return wn(),null}}const ia={class:"mx-auto flex w-full max-w-sm flex-col gap-5 pt-24"},oa={class:"dh-card p-6"},ra={key:0,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},la=["disabled"],aa={__name:"LoginView",emits:["authenticated"],setup(e,{emit:t}){const s=t,n=j(""),i=j(""),o=j(""),r=j(!1),l=bt(()=>n.value.trim()!==""&&i.value!==""&&!r.value);async function a(){if(l.value){o.value="",r.value=!0;try{const p=await sa(n.value.trim(),i.value);s("authenticated",p)}catch(p){o.value=p.status===400||p.status===404?"Invalid email or password.":p.message||"Could not sign in.",i.value=""}finally{r.value=!1}}}return(p,u)=>(w(),E("div",ia,[d("div",oa,[u[4]||(u[4]=d("h1",{class:"text-lg font-bold tracking-[-0.02em] text-strong"},"Sign in",-1)),u[5]||(u[5]=d("p",{class:"mt-1 mb-5 text-sm text-body"}," Superadmin console for the DriverVault API Server. ",-1)),d("form",{class:"flex flex-col gap-4",onSubmit:Kl(a,["prevent"])},[d("div",null,[u[2]||(u[2]=d("label",{class:"dh-label",for:"login-email"},"Email",-1)),ue(d("input",{id:"login-email","onUpdate:modelValue":u[0]||(u[0]=h=>n.value=h),class:"dh-input",type:"email",autocomplete:"username",autofocus:"",placeholder:"you@example.com"},null,512),[[ve,n.value]])]),d("div",null,[u[3]||(u[3]=d("label",{class:"dh-label",for:"login-password"},"Password",-1)),ue(d("input",{id:"login-password","onUpdate:modelValue":u[1]||(u[1]=h=>i.value=h),class:"dh-input",type:"password",autocomplete:"current-password",placeholder:"••••••••"},null,512),[[ve,i.value]])]),o.value?(w(),E("p",ra,D(o.value),1)):V("",!0),d("button",{class:"dh-btn w-full",type:"submit",disabled:!l.value},D(r.value?"Signing in…":"Sign in"),9,la)],32)]),u[6]||(u[6]=d("p",{class:"eyebrow text-center"}," Authenticates against PocketBase through this server ",-1))]))}},ca={class:"dh-card overflow-hidden"},ua={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},fa={key:0,class:"dh-pill bg-danger-soft text-danger"},da={key:0,class:"px-5 py-4 text-sm text-danger"},pa={key:1,class:"w-full text-left text-sm"},ha={class:"px-5 py-3 font-medium text-strong"},ga={class:"data px-5 py-3 text-xs text-muted"},ma={class:"data px-5 py-3 text-right text-xs text-muted"},ba={class:"px-5 py-3 text-right"},va={key:2,class:"px-5 py-4 text-sm text-muted"},_a={__name:"StatusCard",setup(e){const t=j(null),s=j("");let n=null;async function i(){try{t.value=await ne("/api/status",{auth:!1}),s.value=""}catch(r){t.value=null,s.value=r.message||"unreachable"}}vt(()=>{i(),n=setInterval(i,1e4)}),_n(()=>clearInterval(n));const o=r=>r==="ok"?"bg-success-soft text-success":"bg-danger-soft text-danger";return(r,l)=>(w(),E("div",ca,[d("div",ua,[l[1]||(l[1]=d("div",{class:"text-base font-bold tracking-[-0.02em] text-strong"},"Status",-1)),s.value?(w(),E("span",fa,[...l[0]||(l[0]=[d("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1),Ze("unreachable ",-1)])])):V("",!0)]),s.value?(w(),E("div",da,D(s.value),1)):t.value?(w(),E("table",pa,[d("tbody",null,[(w(!0),E(Q,null,Te([{key:"apiServer",label:"API Server",h:t.value.apiServer},{key:"pocketBase",label:"PocketBase",h:t.value.pocketBase},{key:"webApp",label:"Web App",h:t.value.webApp}],a=>(w(),E("tr",{key:a.key,class:"border-t border-subtle first:border-t-0"},[d("td",ha,D(a.label),1),d("td",ga,D(a.h.url||"this process"),1),d("td",ma,D(a.h.latencyMs!=null?a.h.latencyMs+"ms":"—"),1),d("td",ba,[d("span",{class:je(["dh-pill",o(a.h.status)])},[l[2]||(l[2]=d("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),Ze(D(a.h.status),1)],2)])]))),128))])])):(w(),E("div",va,"Checking…"))]))}},ya={class:"dh-card overflow-hidden"},xa={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},wa={class:"flex flex-col gap-4 px-5 py-4"},Sa={class:"grid gap-4 sm:grid-cols-2"},Ta=["placeholder"],Ca={key:0,class:"data text-xs text-muted"},Ea={key:1,class:"rounded-control bg-info-soft px-3 py-2 text-xs text-info"},ka={key:2,class:"rounded-control bg-danger-soft px-3 py-2 text-xs text-danger"},Pa={class:"flex items-center gap-2"},$a=["disabled"],Aa=["disabled"],Oa={__name:"PocketBaseCard",setup(e){const t=j(null),s=j({url:"",adminEmail:"",adminPassword:""}),n=j(null),i=j(""),o=j(""),r=j(!1);async function l(){try{t.value=await ne("/api/admin/pb-config"),s.value={url:t.value.url,adminEmail:t.value.adminEmail,adminPassword:""},n.value=t.value.probe}catch(u){i.value=u.message}}vt(l);async function a(){i.value="",o.value="",r.value=!0;try{n.value=await ne("/api/admin/pb-config/test",{method:"POST",body:s.value}),o.value=n.value.superuser?"Connection OK — superuser authenticated.":n.value.reachable?"PocketBase is reachable, but the service account did not authenticate.":"PocketBase is not reachable at that address."}catch(u){i.value=u.message}finally{r.value=!1}}async function p(){i.value="",o.value="",r.value=!0;try{const u=await ne("/api/admin/pb-config",{method:"PUT",body:s.value});t.value=u.config,n.value=u.config.probe,s.value.adminPassword="",o.value=u.warning||"Saved. The server is now using this PocketBase."}catch(u){i.value=u.message}finally{r.value=!1}}return(u,h)=>{var T,$;return w(),E("div",ya,[d("div",xa,[h[4]||(h[4]=d("div",null,[d("div",{class:"text-base font-bold tracking-[-0.02em] text-strong"},"PocketBase"),d("p",{class:"mt-0.5 text-xs text-muted"},"Database connection used by every endpoint")],-1)),n.value?(w(),E("span",{key:0,class:je(["dh-pill",n.value.superuser?"bg-success-soft text-success":n.value.reachable?"bg-warning-soft text-warning":"bg-danger-soft text-danger"])},[h[3]||(h[3]=d("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),Ze(" "+D(n.value.superuser?"connected":n.value.reachable?"no superuser":"unreachable"),1)],2)):V("",!0)]),d("div",wa,[d("div",null,[h[5]||(h[5]=d("label",{class:"dh-label",for:"pb-url"},"Base URL",-1)),ue(d("input",{id:"pb-url","onUpdate:modelValue":h[0]||(h[0]=F=>s.value.url=F),class:"dh-input",placeholder:"http://10.2.1.10:8027"},null,512),[[ve,s.value.url]])]),d("div",Sa,[d("div",null,[h[6]||(h[6]=d("label",{class:"dh-label",for:"pb-email"},"Superuser email",-1)),ue(d("input",{id:"pb-email","onUpdate:modelValue":h[1]||(h[1]=F=>s.value.adminEmail=F),class:"dh-input",autocomplete:"off"},null,512),[[ve,s.value.adminEmail]])]),d("div",null,[h[7]||(h[7]=d("label",{class:"dh-label",for:"pb-password"},"Superuser password",-1)),ue(d("input",{id:"pb-password","onUpdate:modelValue":h[2]||(h[2]=F=>s.value.adminPassword=F),class:"dh-input",type:"password",autocomplete:"new-password",placeholder:(T=t.value)!=null&&T.adminConfigured?"unchanged":"not set"},null,8,Ta),[[ve,s.value.adminPassword]])])]),($=n.value)!=null&&$.detail?(w(),E("p",Ca,D(n.value.detail),1)):V("",!0),o.value?(w(),E("p",Ea,D(o.value),1)):V("",!0),i.value?(w(),E("p",ka,D(i.value),1)):V("",!0),d("div",Pa,[d("button",{class:"dh-btn",disabled:r.value,onClick:p},"Save & apply",8,$a),d("button",{class:"dh-btn-ghost",disabled:r.value,onClick:a},"Test connection",8,Aa),h[8]||(h[8]=d("span",{class:"flex-1"},null,-1)),h[9]||(h[9]=d("span",{class:"eyebrow"},"persisted to .env",-1))])])])}}},Ma={class:"dh-card overflow-hidden"},Ra={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Ia={key:0,class:"border-b border-subtle bg-sunken px-5 py-4"},Da={class:"grid gap-3 sm:grid-cols-3"},Fa=["disabled"],La={key:1,class:"border-b border-subtle px-5 py-3 text-xs text-danger"},Ua={key:2,class:"px-5 py-6 text-center text-sm text-muted"},Na={class:"flex items-center gap-3 px-5 py-3"},Ha=["onClick"],Va={class:"font-semibold text-strong"},ja={class:"dh-pill bg-sunken text-muted"},Ba={key:0,class:"text-xs text-muted"},Ka=["disabled","onClick"],Wa=["disabled","onClick"],Ga={key:0,class:"bg-sunken px-5 py-4"},za={key:0,class:"data mb-3 text-xs text-muted"},qa={key:1,class:"grid gap-3 sm:grid-cols-2"},Ja={class:"dh-label"},Ya={key:0,class:"text-danger"},Xa=["onUpdate:modelValue"],Za=["value"],Qa=["onUpdate:modelValue","type","placeholder"],ec={key:2,class:"mt-1 text-xs text-muted"},tc={key:2,class:"text-xs text-muted"},sc={key:3,class:"mt-4"},nc={class:"data flex flex-col gap-1 text-xs text-muted"},ic={class:"text-strong"},oc={key:0},rc={key:1},lc={key:4,class:"data mt-3 text-xs text-body"},ac={class:"mt-4 flex items-center gap-2"},cc=["disabled","onClick"],uc=["disabled","onClick"],fc=["disabled","onClick"],dc={key:5,class:"eyebrow mt-2"},pc={__name:"PluginsCard",setup(e){const t=j([]),s=j(""),n=j(!1),i=j(null),o=qt({}),r=qt({}),l=j(!1),a=j({name:"",baseURL:"",provider:""});async function p(){try{const S=await ne("/api/admin/plugins");t.value=S.plugins||[],s.value=""}catch(S){s.value=S.message}}vt(p);function u(S){var C;if(i.value===S.name){i.value=null;return}const x={};for(const M of S.configFields||[])x[M.key]=((C=S.config)==null?void 0:C[M.key])??"";o[S.name]=x,i.value=S.name}async function h(S,x){n.value=!0,r[S.name]="";try{const C=await ne(`/api/admin/plugins/${encodeURIComponent(S.name)}`,{method:"PUT",body:{enabled:x,config:o[S.name]??{}}});r[S.name]=C.warning||"Saved.",await p()}catch(C){r[S.name]=C.message}finally{n.value=!1}}async function T(S){n.value=!0,r[S.name]="Checking…";try{const x=await ne(`/api/admin/plugins/${encodeURIComponent(S.name)}/health`,{method:"POST"});r[S.name]=`${x.health.status}${x.health.detail?" — "+x.health.detail:""}`,await p()}catch(x){r[S.name]=x.message}finally{n.value=!1}}async function $(S){if(confirm(`Remove the external plugin "${S.name}"? Its saved config is deleted.`)){n.value=!0;try{await ne(`/api/admin/plugins/${encodeURIComponent(S.name)}`,{method:"DELETE"}),i.value===S.name&&(i.value=null),await p()}catch(x){r[S.name]=x.message}finally{n.value=!1}}}async function F(){n.value=!0,s.value="";try{await ne("/api/admin/plugins",{method:"POST",body:a.value}),a.value={name:"",baseURL:"",provider:""},l.value=!1,await p()}catch(S){s.value=S.message}finally{n.value=!1}}const y=S=>S==="ok"?"bg-success-soft text-success":S==="degraded"?"bg-warning-soft text-warning":"bg-danger-soft text-danger";return(S,x)=>(w(),E("div",Ma,[d("div",Ra,[x[4]||(x[4]=d("div",null,[d("div",{class:"text-base font-bold tracking-[-0.02em] text-strong"},"Plugins"),d("p",{class:"mt-0.5 text-xs text-muted"},"Third-party service integrations")],-1)),d("button",{class:"dh-btn-ghost",onClick:x[0]||(x[0]=C=>l.value=!l.value)},D(l.value?"Cancel":"Register external"),1)]),l.value?(w(),E("div",Ia,[d("div",Da,[d("div",null,[x[5]||(x[5]=d("label",{class:"dh-label"},"Name",-1)),ue(d("input",{"onUpdate:modelValue":x[1]||(x[1]=C=>a.value.name=C),class:"dh-input",placeholder:"acme-parts"},null,512),[[ve,a.value.name]])]),d("div",null,[x[6]||(x[6]=d("label",{class:"dh-label"},"Base URL",-1)),ue(d("input",{"onUpdate:modelValue":x[2]||(x[2]=C=>a.value.baseURL=C),class:"dh-input",placeholder:"http://127.0.0.1:9100"},null,512),[[ve,a.value.baseURL]])]),d("div",null,[x[7]||(x[7]=d("label",{class:"dh-label"},"Provider",-1)),ue(d("input",{"onUpdate:modelValue":x[3]||(x[3]=C=>a.value.provider=C),class:"dh-input",placeholder:"ACME Corp"},null,512),[[ve,a.value.provider]])])]),d("button",{class:"dh-btn mt-3",disabled:n.value||!a.value.name||!a.value.baseURL,onClick:F}," Register ",8,Fa)])):V("",!0),s.value?(w(),E("p",La,D(s.value),1)):V("",!0),t.value.length?V("",!0):(w(),E("p",Ua," No plugins yet. Register an external one above, or compile a built-in connector. ")),(w(!0),E(Q,null,Te(t.value,C=>(w(),E("div",{key:C.name,class:"border-t border-subtle first:border-t-0"},[d("div",Na,[d("button",{class:"flex flex-1 items-center gap-3 text-left",onClick:M=>u(C)},[d("span",Va,D(C.name),1),d("span",ja,D(C.kind||"builtin"),1),C.provider?(w(),E("span",Ba,D(C.provider),1)):V("",!0),C.health?(w(),E("span",{key:1,class:je(["dh-pill",y(C.health.status)])},[x[8]||(x[8]=d("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),Ze(D(C.health.status),1)],2)):V("",!0)],8,Ha),d("span",{class:je(["dh-pill",C.enabled?"bg-success-soft text-success":"bg-sunken text-muted"])},D(C.enabled?"enabled":"disabled"),3),d("button",{class:"dh-btn-ghost",disabled:n.value,onClick:M=>T(C)},"Health",8,Ka),d("button",{class:"dh-btn-ghost",disabled:n.value,onClick:M=>u(C)},D(i.value===C.name?"Close":"Configure"),9,Wa)]),i.value===C.name?(w(),E("div",Ga,[C.baseURL?(w(),E("div",za,D(C.baseURL),1)):V("",!0),(C.configFields||[]).length?(w(),E("div",qa,[(w(!0),E(Q,null,Te(C.configFields,M=>(w(),E("div",{key:M.key},[d("label",Ja,[Ze(D(M.label||M.key),1),M.required?(w(),E("span",Ya," *")):V("",!0)]),M.type==="select"?ue((w(),E("select",{key:0,"onUpdate:modelValue":R=>o[C.name][M.key]=R,class:"dh-select"},[(w(!0),E(Q,null,Te(M.options||[],R=>(w(),E("option",{key:R.value,value:R.value},D(R.label||R.value),9,Za))),128))],8,Xa)),[[ys,o[C.name][M.key]]]):ue((w(),E("input",{key:1,"onUpdate:modelValue":R=>o[C.name][M.key]=R,class:"dh-input",type:M.type==="password"?"password":M.type==="number"?"number":"text",placeholder:M.default||"",autocomplete:"off"},null,8,Qa)),[[Hl,o[C.name][M.key]]]),M.help?(w(),E("p",ec,D(M.help),1)):V("",!0)]))),128))])):(w(),E("p",tc,"This plugin takes no configuration.")),(C.capabilities||[]).length?(w(),E("div",sc,[x[9]||(x[9]=d("div",{class:"eyebrow mb-1.5"},"Capabilities",-1)),d("ul",nc,[(w(!0),E(Q,null,Te(C.capabilities,M=>(w(),E("li",{key:M.id},[d("span",ic,D(M.id),1),M.method||M.endpoint?(w(),E("span",oc," — "+D(M.method)+" "+D(M.endpoint),1)):V("",!0),M.description?(w(),E("span",rc," · "+D(M.description),1)):V("",!0)]))),128))])])):V("",!0),r[C.name]?(w(),E("p",lc,D(r[C.name]),1)):V("",!0),d("div",ac,[d("button",{class:"dh-btn",disabled:n.value,onClick:M=>h(C,!0)},D(C.enabled?"Save":"Save & enable"),9,cc),C.enabled?(w(),E("button",{key:0,class:"dh-btn-ghost",disabled:n.value,onClick:M=>h(C,!1)}," Disable ",8,uc)):V("",!0),x[10]||(x[10]=d("span",{class:"flex-1"},null,-1)),C.kind==="external"?(w(),E("button",{key:1,class:"dh-btn-danger",disabled:n.value,onClick:M=>$(C)}," Remove ",8,fc)):V("",!0)]),C.kind!=="external"?(w(),E("p",dc," Built-in plugins can be disabled but not removed ")):V("",!0)])):V("",!0)]))),128))]))}},hc={class:"dh-card overflow-hidden"},gc={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},mc={class:"mt-0.5 text-xs text-muted"},bc={key:0,class:"border-b border-subtle px-5 py-3 text-xs text-danger"},vc={key:1,class:"border-b border-subtle bg-sunken px-5 py-4"},_c={class:"grid gap-3 sm:grid-cols-2"},yc={class:"dh-label"},xc=["value"],wc={key:0},Sc=["value"],Tc={class:"mt-3 flex items-center gap-2"},Cc=["disabled"],Ec=["disabled"],kc={key:2,class:"px-5 py-6 text-center text-sm text-muted"},Pc={key:3,class:"w-full text-left text-sm"},$c={class:"data px-5 py-2.5 text-xs text-strong"},Ac={key:0,class:"eyebrow ml-1"},Oc={class:"px-5 py-2.5 text-body"},Mc={class:"px-5 py-2.5 text-body"},Rc={class:"px-5 py-2.5"},Ic={class:"px-5 py-2.5 text-right whitespace-nowrap"},Dc=["onClick"],Fc=["disabled","onClick"],Lc={__name:"UsersCard",setup(e){const t=j([]),s=j([]),n=j(""),i=j(!1),o=j(null),r=j({}),l=bt(()=>ht.value?["user","admin","superadmin"]:["user","admin"]);async function a(){try{const[y,S]=await Promise.all([ne("/api/users"),ne("/api/orgs")]);t.value=y.users||[],s.value=S.organizations||[],n.value=""}catch(y){n.value=y.message}}vt(a);function p(){var y;o.value="new",r.value={email:"",name:"",password:"",role:"user",organization:ht.value?"":((y=ce.value)==null?void 0:y.organization)||""}}function u(y){o.value=y.id,r.value={email:y.email,name:y.name||"",password:"",role:y.role,organization:y.organization||""}}function h(){o.value=null,n.value=""}async function T(){i.value=!0,n.value="";try{if(o.value==="new")await ne("/api/users",{method:"POST",body:r.value});else{const y={...r.value};y.password||delete y.password,await ne(`/api/users/${o.value}`,{method:"PATCH",body:y})}o.value=null,await a()}catch(y){n.value=y.message}finally{i.value=!1}}async function $(y){if(confirm(`Delete ${y.email}? This cannot be undone.`)){i.value=!0,n.value="";try{await ne(`/api/users/${y.id}`,{method:"DELETE"}),await a()}catch(S){n.value=S.message}finally{i.value=!1}}}const F=y=>y==="superadmin"?"bg-info-soft text-info":y==="admin"?"bg-warning-soft text-warning":"bg-sunken text-muted";return(y,S)=>(w(),E("div",hc,[d("div",gc,[d("div",null,[S[5]||(S[5]=d("div",{class:"text-base font-bold tracking-[-0.02em] text-strong"},"Users",-1)),d("p",mc,D(se(ht)?"All organizations":"Your organization"),1)]),d("button",{class:"dh-btn",onClick:p},"New user")]),n.value?(w(),E("p",bc,D(n.value),1)):V("",!0),o.value?(w(),E("div",vc,[d("div",_c,[d("div",null,[S[6]||(S[6]=d("label",{class:"dh-label"},"Email",-1)),ue(d("input",{"onUpdate:modelValue":S[0]||(S[0]=x=>r.value.email=x),class:"dh-input",type:"email",autocomplete:"off"},null,512),[[ve,r.value.email]])]),d("div",null,[S[7]||(S[7]=d("label",{class:"dh-label"},"Name",-1)),ue(d("input",{"onUpdate:modelValue":S[1]||(S[1]=x=>r.value.name=x),class:"dh-input",autocomplete:"off"},null,512),[[ve,r.value.name]])]),d("div",null,[d("label",yc," Password"+D(o.value==="new"?"":" (blank = unchanged)"),1),ue(d("input",{"onUpdate:modelValue":S[2]||(S[2]=x=>r.value.password=x),class:"dh-input",type:"password",autocomplete:"new-password",placeholder:"min 8 characters"},null,512),[[ve,r.value.password]])]),d("div",null,[S[8]||(S[8]=d("label",{class:"dh-label"},"Role",-1)),ue(d("select",{"onUpdate:modelValue":S[3]||(S[3]=x=>r.value.role=x),class:"dh-select"},[(w(!0),E(Q,null,Te(l.value,x=>(w(),E("option",{key:x,value:x},D(x),9,xc))),128))],512),[[ys,r.value.role]])]),se(ht)?(w(),E("div",wc,[S[10]||(S[10]=d("label",{class:"dh-label"},"Organization",-1)),ue(d("select",{"onUpdate:modelValue":S[4]||(S[4]=x=>r.value.organization=x),class:"dh-select"},[S[9]||(S[9]=d("option",{value:""},"— none —",-1)),(w(!0),E(Q,null,Te(s.value,x=>(w(),E("option",{key:x.id,value:x.id},D(x.name),9,Sc))),128))],512),[[ys,r.value.organization]])])):V("",!0)]),d("div",Tc,[d("button",{class:"dh-btn",disabled:i.value,onClick:T},D(o.value==="new"?"Create":"Save"),9,Cc),d("button",{class:"dh-btn-ghost",disabled:i.value,onClick:h},"Cancel",8,Ec)])])):V("",!0),t.value.length?(w(),E("table",Pc,[S[11]||(S[11]=d("thead",null,[d("tr",{class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},[d("th",null,"Email"),d("th",null,"Name"),d("th",null,"Organization"),d("th",null,"Role"),d("th")])],-1)),d("tbody",null,[(w(!0),E(Q,null,Te(t.value,x=>{var C,M;return w(),E("tr",{key:x.id,class:"border-t border-subtle transition-colors hover:bg-sunken"},[d("td",$c,[Ze(D(x.email)+" ",1),x.id===((C=se(ce))==null?void 0:C.id)?(w(),E("span",Ac,"you")):V("",!0)]),d("td",Oc,D(x.name||"—"),1),d("td",Mc,D(x.organizationName||"—"),1),d("td",Rc,[d("span",{class:je(["dh-pill",F(x.role)])},D(x.role),3)]),d("td",Ic,[d("button",{class:"dh-btn-ghost",onClick:R=>u(x)},"Edit",8,Dc),x.id!==((M=se(ce))==null?void 0:M.id)?(w(),E("button",{key:0,class:"dh-btn-danger ml-1.5",disabled:i.value,onClick:R=>$(x)}," Delete ",8,Fc)):V("",!0)])])}),128))])])):(w(),E("p",kc,"No users."))]))}},Uc={class:"dh-card overflow-hidden"},Nc={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Hc={key:0,class:"border-b border-subtle px-5 py-3 text-xs text-danger"},Vc={key:1,class:"border-b border-subtle bg-sunken px-5 py-4"},jc={class:"mt-3 flex items-center gap-2"},Bc=["disabled"],Kc=["disabled"],Wc={key:2,class:"px-5 py-6 text-center text-sm text-muted"},Gc={key:3,class:"w-full text-left text-sm"},zc={class:"px-5 py-2.5 font-medium text-strong"},qc={class:"data px-5 py-2.5 text-xs text-muted"},Jc={class:"px-5 py-2.5 text-right whitespace-nowrap"},Yc=["onClick"],Xc=["disabled","onClick"],Zc={__name:"OrgsCard",setup(e){const t=j([]),s=j(""),n=j(!1),i=j(null),o=j("");async function r(){try{const T=await ne("/api/orgs");t.value=T.organizations||[],s.value=""}catch(T){s.value=T.message}}vt(r);function l(){i.value="new",o.value=""}function a(T){i.value=T.id,o.value=T.name}function p(){i.value=null,s.value=""}async function u(){n.value=!0,s.value="";try{i.value==="new"?await ne("/api/orgs",{method:"POST",body:{name:o.value}}):await ne(`/api/orgs/${i.value}`,{method:"PATCH",body:{name:o.value}}),i.value=null,await r()}catch(T){s.value=T.message}finally{n.value=!1}}async function h(T){if(confirm(`Delete the organization "${T.name}"?`)){n.value=!0,s.value="";try{await ne(`/api/orgs/${T.id}`,{method:"DELETE"}),await r()}catch($){s.value=$.message}finally{n.value=!1}}}return(T,$)=>(w(),E("div",Uc,[d("div",Nc,[$[1]||($[1]=d("div",null,[d("div",{class:"text-base font-bold tracking-[-0.02em] text-strong"},"Organizations"),d("p",{class:"mt-0.5 text-xs text-muted"},"Tenants users belong to")],-1)),se(ht)?(w(),E("button",{key:0,class:"dh-btn",onClick:l},"New organization")):V("",!0)]),s.value?(w(),E("p",Hc,D(s.value),1)):V("",!0),i.value?(w(),E("div",Vc,[$[2]||($[2]=d("label",{class:"dh-label"},"Name",-1)),ue(d("input",{"onUpdate:modelValue":$[0]||($[0]=F=>o.value=F),class:"dh-input",placeholder:"Acme Fleet",onKeyup:Gl(u,["enter"])},null,544),[[ve,o.value]]),d("div",jc,[d("button",{class:"dh-btn",disabled:n.value||!o.value.trim(),onClick:u},D(i.value==="new"?"Create":"Save"),9,Bc),d("button",{class:"dh-btn-ghost",disabled:n.value,onClick:p},"Cancel",8,Kc)])])):V("",!0),t.value.length?(w(),E("table",Gc,[$[3]||($[3]=d("thead",null,[d("tr",{class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},[d("th",null,"Name"),d("th",null,"ID"),d("th")])],-1)),d("tbody",null,[(w(!0),E(Q,null,Te(t.value,F=>(w(),E("tr",{key:F.id,class:"border-t border-subtle transition-colors hover:bg-sunken"},[d("td",zc,D(F.name),1),d("td",qc,D(F.id),1),d("td",Jc,[se(ht)?(w(),E(Q,{key:0},[d("button",{class:"dh-btn-ghost",onClick:y=>a(F)},"Rename",8,Yc),d("button",{class:"dh-btn-danger ml-1.5",disabled:n.value,onClick:y=>h(F)}," Delete ",8,Xc)],64)):V("",!0)])]))),128))])])):(w(),E("p",Wc," No organizations yet. "))]))}},Qc={class:"dh-card overflow-hidden"},eu={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},tu={class:"text-base font-bold tracking-[-0.02em] text-strong"},su={class:"eyebrow"},nu={class:"w-full text-left text-sm"},iu={class:"data border-t border-subtle px-5 py-2.5 text-xs whitespace-nowrap"},ou={class:"text-strong"},ru={class:"border-t border-subtle px-5 py-2.5 text-body"},tt={__name:"EndpointTable",props:{title:String,auth:String,endpoints:Array},setup(e){const t={GET:"text-success",POST:"text-brandtext",PATCH:"text-warning",DELETE:"text-danger"};return(s,n)=>(w(),E("div",Qc,[d("div",eu,[d("div",tu,D(e.title),1),d("span",su,D(e.auth),1)]),d("table",nu,[n[0]||(n[0]=d("thead",null,[d("tr",{class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},[d("th",null,"Endpoint"),d("th",null,"Description")])],-1)),d("tbody",null,[(w(!0),E(Q,null,Te(e.endpoints,i=>(w(),E("tr",{key:i.method+i.path,class:"transition-colors hover:bg-sunken"},[d("td",iu,[d("span",{class:je(["font-semibold",t[i.method]])},D(i.method),3),d("span",ou,D(i.path),1)]),d("td",ru,D(i.desc),1)]))),128))])])]))}},lu={class:"mx-auto flex max-w-4xl flex-col gap-6 px-6 pt-12 pb-16"},au={class:"flex items-center gap-3"},cu={class:"inline-flex items-center gap-2.5 select-none"},uu={class:"h-8 w-8 shrink-0",viewBox:"0 0 48 48",fill:"none","aria-hidden":"true"},fu={transform:"translate(7 0) skewX(-13)"},du=["fill"],pu=["fill"],hu=["fill"],gu={key:0,class:"data hidden text-xs text-muted sm:inline"},mu={key:0},bu={key:1,class:"dh-pill bg-info-soft text-info"},vu=["title"],_u={key:0,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"},yu={key:1,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"},xu={key:0,class:"eyebrow py-16 text-center"},wu={class:"flex flex-wrap gap-1.5"},Su=["onClick"],Tu={key:6,class:"eyebrow text-center"},Cu={__name:"App",setup(e){const t=j(!0),s=j("overview");vt(async()=>{await na(),t.value=!1});const n=bt(()=>{const F=[{id:"overview",label:"Overview"}];return ci.value&&F.push({id:"users",label:"Users"},{id:"orgs",label:"Organizations"}),ht.value&&F.push({id:"pocketbase",label:"PocketBase"},{id:"plugins",label:"Plugins"}),F.push({id:"api",label:"API"}),F});function i(){wn(),s.value="overview"}const o=bt(()=>Ct.value==="dark"?["#60a5fa","#93c5fd","#ffffff"]:["var(--brand-700)","var(--brand-500)","var(--brand-400)"]),r=[{method:"GET",path:"/api/health",desc:"Liveness probe (no auth)"},{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"}],l=[{method:"GET",path:"/api/auth/me",desc:"Identity of the bearer token"},{method:"GET",path:"/api/identity",desc:"Identity incl. role + organization"}],a=[{method:"GET",path:"/api/cars",desc:"List owned + shared cars"},{method:"POST",path:"/api/cars",desc:"Create a car"},{method:"GET",path:"/api/cars/{id}",desc:"Fetch one car"},{method:"PATCH",path:"/api/cars/{id}",desc:"Update a car"},{method:"DELETE",path:"/api/cars/{id}",desc:"Delete a car (owner only)"},{method:"GET",path:"/api/cars/{id}/service-records",desc:"A car's service history"},{method:"GET",path:"/api/cars/{id}/parts",desc:"A car's parts catalog"},{method:"GET",path:"/api/cars/{id}/shares",desc:"Who a car is shared with (owner)"},{method:"POST",path:"/api/cars/{id}/shares",desc:"Share a car by email (owner)"},{method:"DELETE",path:"/api/cars/{id}/shares/{userId}",desc:"Revoke a share (owner)"}],p=[{method:"GET",path:"/api/service-records",desc:"List service records"},{method:"POST",path:"/api/service-records",desc:"Log a service record"},{method:"GET",path:"/api/service-records/{id}",desc:"Fetch one record"},{method:"PATCH",path:"/api/service-records/{id}",desc:"Update a record"},{method:"DELETE",path:"/api/service-records/{id}",desc:"Delete a record"}],u=[{method:"GET",path:"/api/parts",desc:"List parts"},{method:"POST",path:"/api/parts",desc:"Add a part"},{method:"GET",path:"/api/parts/{id}",desc:"Fetch one part"},{method:"PATCH",path:"/api/parts/{id}",desc:"Update a part"},{method:"DELETE",path:"/api/parts/{id}",desc:"Delete a part"}],h=[{method:"GET",path:"/api/me",desc:"Current user profile"},{method:"PATCH",path:"/api/me",desc:"Update profile"},{method:"POST",path:"/api/me/password",desc:"Change password"},{method:"POST",path:"/api/me/avatar",desc:"Upload avatar"},{method:"GET",path:"/api/me/avatar",desc:"Fetch avatar"},{method:"DELETE",path:"/api/me/avatar",desc:"Remove avatar"},{method:"POST",path:"/api/me/verify/request",desc:"Request email verification"},{method:"GET",path:"/api/me/export",desc:"Export your data"},{method:"POST",path:"/api/me/import",desc:"Import data"},{method:"POST",path:"/api/me/delete",desc:"Request account deletion"},{method:"POST",path:"/api/me/delete/cancel",desc:"Cancel deletion request"},{method:"DELETE",path:"/api/me",desc:"Finalize account deletion"}],T=[{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)"}],$=[{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"}];return(F,y)=>(w(),E("div",lu,[d("div",au,[d("span",cu,[(w(),E("svg",uu,[d("g",fu,[d("rect",{x:"9",y:"16",width:"6",height:"16",rx:"3",fill:o.value[0]},null,8,du),d("rect",{x:"19",y:"12",width:"6",height:"24",rx:"3",fill:o.value[1]},null,8,pu),d("rect",{x:"29",y:"8",width:"6",height:"32",rx:"3",fill:o.value[2]},null,8,hu)])])),y[1]||(y[1]=d("span",{class:"text-2xl leading-none font-extrabold tracking-[-0.03em] italic"},[d("span",{class:"text-strong"},"Driver"),d("span",{class:"text-brandtext"},"Vault")],-1))]),y[5]||(y[5]=d("span",{class:"eyebrow mt-1.5"},"API server",-1)),y[6]||(y[6]=d("div",{class:"flex-1"},null,-1)),se(ce)?(w(),E("span",gu,[Ze(D(se(ce).email),1),se(ce).organizationName?(w(),E("span",mu," · "+D(se(ce).organizationName),1)):V("",!0)])):V("",!0),se(ce)?(w(),E("span",bu,D(se(ce).role),1)):V("",!0),d("button",{class:"dh-btn-ghost",title:se(Ct)==="dark"?"Switch to light theme":"Switch to dark theme",onClick:y[0]||(y[0]=(...S)=>se(ai)&&se(ai)(...S))},[se(Ct)==="dark"?(w(),E("svg",_u,[...y[2]||(y[2]=[d("circle",{cx:"12",cy:"12",r:"4"},null,-1),d("path",{"stroke-linecap":"round",d:"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"},null,-1)])])):(w(),E("svg",yu,[...y[3]||(y[3]=[d("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"},null,-1)])])),y[4]||(y[4]=Ze(" Theme ",-1))],8,vu),se($t)?(w(),E("button",{key:2,class:"dh-btn-ghost",onClick:i},"Sign out")):V("",!0)]),t.value?(w(),E("p",xu,"Loading…")):se($t)?(w(),E(Q,{key:2},[d("nav",wu,[(w(!0),E(Q,null,Te(n.value,S=>(w(),E("button",{key:S.id,class:je(["dh-btn-ghost",s.value===S.id?"border-accent text-brandtext":""]),onClick:x=>s.value=S.id},D(S.label),11,Su))),128))]),s.value==="overview"?(w(),pt(_a,{key:0})):s.value==="users"?(w(),pt(Lc,{key:1})):s.value==="orgs"?(w(),pt(Zc,{key:2})):s.value==="pocketbase"?(w(),pt(Oa,{key:3})):s.value==="plugins"?(w(),pt(pc,{key:4})):s.value==="api"?(w(),E(Q,{key:5},[le(tt,{title:"Public",auth:"No auth",endpoints:r}),le(tt,{title:"Identity",auth:"Bearer token",endpoints:l}),le(tt,{title:"Cars",auth:"Bearer token",endpoints:a}),le(tt,{title:"Service records",auth:"Bearer token",endpoints:p}),le(tt,{title:"Parts",auth:"Bearer token",endpoints:u}),le(tt,{title:"Account",auth:"Bearer token",endpoints:h}),le(tt,{title:"Management",auth:"Admin / superadmin",endpoints:T}),le(tt,{title:"Superadmin",auth:"Superadmin",endpoints:$})],64)):V("",!0),!se(ci)&&s.value==="overview"?(w(),E("p",Tu," Signed in as a standard user — management sections need an admin role ")):V("",!0)],64)):(w(),pt(aa,{key:1})),y[7]||(y[7]=d("p",{class:"eyebrow text-center"},"DriverVault — car maintenance & service tracker.",-1))]))}};Jl(Cu).mount("#app"); diff --git a/API Server/internal/api/dist/assets/index-DfVJ8vbT.css b/API Server/internal/api/dist/assets/index-DfVJ8vbT.css deleted file mode 100644 index e2c98e1..0000000 --- a/API Server/internal/api/dist/assets/index-DfVJ8vbT.css +++ /dev/null @@ -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:inline-flex}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-4{height:calc(var(--spacing) * 4)}.h-8{height:calc(var(--spacing) * 8)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-4{width:calc(var(--spacing) * 4)}.w-8{width:calc(var(--spacing) * 8)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.overflow-hidden{overflow:hidden}.rounded-full{border-radius:3.40282e38px}.rounded-pill{border-radius:999px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-subtle{border-color:var(--border-subtle)}.bg-current{background-color:currentColor}.bg-danger-soft{background-color:var(--danger-100)}.bg-success-soft{background-color:var(--success-100)}.bg-sunken{background-color:var(--surface-sunken)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-1{padding-block:var(--spacing)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.text-center{text-align:center}.text-left{text-align:left}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.02em\]{--tw-tracking:-.02em;letter-spacing:-.02em}.tracking-\[-0\.03em\]{--tw-tracking:-.03em;letter-spacing:-.03em}.whitespace-nowrap{white-space:nowrap}.text-body{color:var(--text-body)}.text-brandtext{color:var(--text-brand)}.text-danger{color:var(--danger-600)}.text-muted{color:var(--text-muted)}.text-strong{color:var(--text-strong)}.text-success{color:var(--success-600)}.text-warning{color:var(--warning-600)}.italic{font-style:italic}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}@media(hover:hover){.hover\:bg-sunken:hover{background-color:var(--surface-sunken)}}.\[\&\>th\]\:px-5>th{padding-inline:calc(var(--spacing) * 5)}.\[\&\>th\]\:py-2\.5>th{padding-block:calc(var(--spacing) * 2.5)}.\[\&\>th\]\:font-medium>th{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}}:root{color-scheme:light;--brand-900:#0b1730;--brand-800:#0f1e3d;--brand-700:#1e40af;--brand-600:#2563eb;--brand-500:#3b82f6;--brand-400:#60a5fa;--brand-300:#93c5fd;--brand-200:#c7dbfb;--brand-100:#e8f0fd;--ink-900:#0f1e3d;--ink-700:#28374f;--ink-600:#3e4e68;--ink-500:#5c6b85;--ink-400:#7a8aa6;--ink-300:#b4bece;--ink-200:#d6deea;--ink-100:#e4e9f2;--ink-50:#eef2f8;--ink-25:#f7f9fc;--white:#fff;--success-600:#1f8a5b;--success-100:#e1f3ea;--warning-600:#d9822b;--warning-100:#fbeddd;--danger-600:#dc2a45;--danger-100:#fbe3e7;--info-600:#2563eb;--info-100:#e8f0fd;--surface-page:var(--ink-25);--surface-card:var(--white);--surface-sunken:var(--ink-50);--border-subtle:var(--ink-100);--border-default:var(--ink-200);--border-strong:var(--ink-300);--text-strong:var(--ink-900);--text-body:var(--ink-600);--text-muted:var(--ink-400);--text-brand:var(--brand-700);--accent:var(--brand-600);--accent-hover:var(--brand-700);--focus-ring:var(--brand-400);--shadow-xs:0 1px 2px #0f1e3d0f;--shadow-sm:0 1px 2px #0f1e3d0a, 0 2px 6px #0f1e3d0f;--shadow-md:0 1px 2px #0f1e3d0a, 0 12px 30px -12px #0f1e3d24;--font-display:"Archivo", system-ui, sans-serif;--font-sans:"Archivo", system-ui, sans-serif;--font-mono:"DM Mono", ui-monospace, "SF Mono", monospace}html.dark{color-scheme:dark;--success-100:#12352a;--warning-100:#3a2a16;--danger-100:#3a1620;--info-100:#122a4d;--surface-page:#0b1730;--surface-card:#13233f;--surface-sunken:#0f1e38;--border-subtle:#21324f;--border-default:#2c3f5e;--border-strong:#3c5173;--text-strong:#f2f6fc;--text-body:#b7c4d9;--text-muted:#7c8ca8;--text-brand:#93c5fd;--accent:#3b82f6;--accent-hover:#60a5fa;--focus-ring:#60a5fa;--success-600:#35b27a;--warning-600:#e0a03a;--danger-600:#e85c74;--shadow-xs:0 1px 2px #0006;--shadow-sm:0 1px 2px #0006, 0 2px 6px #0006;--shadow-md:0 1px 2px #00000059, 0 12px 30px -12px #0000008c}html,body,#app{height:100%}body{font-family:var(--font-sans);background:var(--surface-page);color:var(--text-body);-webkit-font-smoothing:antialiased;margin:0}.eyebrow{font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.16em;color:var(--text-muted);font-size:11px}.data{font-family:var(--font-mono);letter-spacing:.02em;font-variant-numeric:tabular-nums}.dh-btn-ghost{border-radius:var(--radius-control);border:1px solid var(--border-default);background:var(--surface-card);height:34px;color:var(--text-strong);font-family:var(--font-sans);cursor:pointer;justify-content:center;align-items:center;gap:8px;padding:0 12px;font-size:.8125rem;font-weight:600;transition:background-color .14s,border-color .14s;display:inline-flex}.dh-btn-ghost:hover{background:var(--surface-sunken);border-color:var(--border-strong)}.dh-btn-ghost:focus-visible{box-shadow:0 0 0 3px var(--focus-ring);outline:none}.dh-card{border:1px solid var(--border-subtle);background:var(--surface-card);border-radius:var(--radius-card);box-shadow:var(--shadow-sm)}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false} diff --git a/API Server/internal/api/dist/assets/index-i1JZk1ZM.css b/API Server/internal/api/dist/assets/index-i1JZk1ZM.css new file mode 100644 index 0000000..7c9044d --- /dev/null +++ b/API Server/internal/api/dist/assets/index-i1JZk1ZM.css @@ -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-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-flex{display:inline-flex}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-4{height:calc(var(--spacing) * 4)}.h-8{height:calc(var(--spacing) * 8)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-4{width:calc(var(--spacing) * 4)}.w-8{width:calc(var(--spacing) * 8)}.w-full{width:100%}.max-w-4xl{max-width:var(--container-4xl)}.max-w-sm{max-width:var(--container-sm)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.overflow-hidden{overflow:hidden}.rounded-control{border-radius:12px}.rounded-full{border-radius:3.40282e38px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-accent{border-color:var(--accent)}.border-subtle{border-color:var(--border-subtle)}.bg-current{background-color:currentColor}.bg-danger-soft{background-color:var(--danger-100)}.bg-info-soft{background-color:var(--info-100)}.bg-success-soft{background-color:var(--success-100)}.bg-sunken{background-color:var(--surface-sunken)}.bg-warning-soft{background-color:var(--warning-100)}.p-6{padding:calc(var(--spacing) * 6)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pt-24{padding-top:calc(var(--spacing) * 24)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.leading-none{--tw-leading:1;line-height:1}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-extrabold{--tw-font-weight:var(--font-weight-extrabold);font-weight:var(--font-weight-extrabold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.02em\]{--tw-tracking:-.02em;letter-spacing:-.02em}.tracking-\[-0\.03em\]{--tw-tracking:-.03em;letter-spacing:-.03em}.whitespace-nowrap{white-space:nowrap}.text-body{color:var(--text-body)}.text-brandtext{color:var(--text-brand)}.text-danger{color:var(--danger-600)}.text-info{color:var(--info-600)}.text-muted{color:var(--text-muted)}.text-strong{color:var(--text-strong)}.text-success{color:var(--success-600)}.text-warning{color:var(--warning-600)}.italic{font-style:italic}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-none{-webkit-user-select:none;user-select:none}.first\:border-t-0:first-child{border-top-style:var(--tw-border-style);border-top-width:0}@media(hover:hover){.hover\:bg-sunken:hover{background-color:var(--surface-sunken)}}@media(min-width:40rem){.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}.\[\&\>th\]\:px-5>th{padding-inline:calc(var(--spacing) * 5)}.\[\&\>th\]\:py-2\.5>th{padding-block:calc(var(--spacing) * 2.5)}.\[\&\>th\]\:font-medium>th{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}}:root{color-scheme:light;--brand-900:#0b1730;--brand-800:#0f1e3d;--brand-700:#1e40af;--brand-600:#2563eb;--brand-500:#3b82f6;--brand-400:#60a5fa;--brand-300:#93c5fd;--brand-200:#c7dbfb;--brand-100:#e8f0fd;--ink-900:#0f1e3d;--ink-700:#28374f;--ink-600:#3e4e68;--ink-500:#5c6b85;--ink-400:#7a8aa6;--ink-300:#b4bece;--ink-200:#d6deea;--ink-100:#e4e9f2;--ink-50:#eef2f8;--ink-25:#f7f9fc;--white:#fff;--success-600:#1f8a5b;--success-100:#e1f3ea;--warning-600:#d9822b;--warning-100:#fbeddd;--danger-600:#dc2a45;--danger-100:#fbe3e7;--info-600:#2563eb;--info-100:#e8f0fd;--surface-page:var(--ink-25);--surface-card:var(--white);--surface-sunken:var(--ink-50);--border-subtle:var(--ink-100);--border-default:var(--ink-200);--border-strong:var(--ink-300);--text-strong:var(--ink-900);--text-body:var(--ink-600);--text-muted:var(--ink-400);--text-brand:var(--brand-700);--accent:var(--brand-600);--accent-hover:var(--brand-700);--focus-ring:var(--brand-400);--shadow-xs:0 1px 2px #0f1e3d0f;--shadow-sm:0 1px 2px #0f1e3d0a, 0 2px 6px #0f1e3d0f;--shadow-md:0 1px 2px #0f1e3d0a, 0 12px 30px -12px #0f1e3d24;--font-display:"Archivo", system-ui, sans-serif;--font-sans:"Archivo", system-ui, sans-serif;--font-mono:"DM Mono", ui-monospace, "SF Mono", monospace}html.dark{color-scheme:dark;--success-100:#12352a;--warning-100:#3a2a16;--danger-100:#3a1620;--info-100:#122a4d;--surface-page:#0b1730;--surface-card:#13233f;--surface-sunken:#0f1e38;--border-subtle:#21324f;--border-default:#2c3f5e;--border-strong:#3c5173;--text-strong:#f2f6fc;--text-body:#b7c4d9;--text-muted:#7c8ca8;--text-brand:#93c5fd;--accent:#3b82f6;--accent-hover:#60a5fa;--focus-ring:#60a5fa;--success-600:#35b27a;--warning-600:#e0a03a;--danger-600:#e85c74;--shadow-xs:0 1px 2px #0006;--shadow-sm:0 1px 2px #0006, 0 2px 6px #0006;--shadow-md:0 1px 2px #00000059, 0 12px 30px -12px #0000008c}html,body,#app{height:100%}body{font-family:var(--font-sans);background:var(--surface-page);color:var(--text-body);-webkit-font-smoothing:antialiased;margin:0}.eyebrow{font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.16em;color:var(--text-muted);font-size:11px}.data{font-family:var(--font-mono);letter-spacing:.02em;font-variant-numeric:tabular-nums}.dh-btn-ghost{border-radius:var(--radius-control);border:1px solid var(--border-default);background:var(--surface-card);height:34px;color:var(--text-strong);font-family:var(--font-sans);cursor:pointer;justify-content:center;align-items:center;gap:8px;padding:0 12px;font-size:.8125rem;font-weight:600;transition:background-color .14s,border-color .14s;display:inline-flex}.dh-btn-ghost:hover{background:var(--surface-sunken);border-color:var(--border-strong)}.dh-btn-ghost:focus-visible{box-shadow:0 0 0 3px var(--focus-ring);outline:none}.dh-btn{border-radius:var(--radius-control);background:var(--accent);color:#fff;height:34px;font-family:var(--font-sans);cursor:pointer;border:1px solid #0000;justify-content:center;align-items:center;gap:8px;padding:0 14px;font-size:.8125rem;font-weight:600;transition:background-color .14s;display:inline-flex}.dh-btn:hover:not(:disabled){background:var(--accent-hover)}.dh-btn:focus-visible{box-shadow:0 0 0 3px var(--focus-ring);outline:none}.dh-btn:disabled,.dh-btn-ghost:disabled,.dh-btn-danger:disabled{opacity:.55;cursor:not-allowed}.dh-btn-danger{border-radius:var(--radius-control);border:1px solid var(--border-default);height:30px;color:var(--danger-600);font-family:var(--font-sans);cursor:pointer;background:0 0;justify-content:center;align-items:center;gap:6px;padding:0 10px;font-size:.75rem;font-weight:600;transition:background-color .14s,border-color .14s;display:inline-flex}.dh-btn-danger:hover:not(:disabled){background:var(--danger-100);border-color:var(--danger-600)}.dh-input,.dh-select{border-radius:var(--radius-control);border:1px solid var(--border-default);background:var(--surface-card);width:100%;height:36px;color:var(--text-strong);font-family:var(--font-sans);padding:0 10px;font-size:.8125rem;transition:border-color .14s}.dh-input::placeholder{color:var(--text-muted)}.dh-input:focus,.dh-select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring);outline:none}.dh-label{font-family:var(--font-mono);text-transform:uppercase;letter-spacing:.16em;color:var(--text-muted);margin-bottom:5px;font-size:10px;display:block}.dh-pill{border-radius:var(--radius-pill);font-family:var(--font-mono);white-space:nowrap;align-items:center;gap:6px;padding:3px 9px;font-size:11px;font-weight:500;display:inline-flex}.dh-card{border:1px solid var(--border-subtle);background:var(--surface-card);border-radius:var(--radius-card);box-shadow:var(--shadow-sm)}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false} diff --git a/API Server/internal/api/dist/assets/index-o_I931vi.js b/API Server/internal/api/dist/assets/index-o_I931vi.js deleted file mode 100644 index 55ae333..0000000 --- a/API Server/internal/api/dist/assets/index-o_I931vi.js +++ /dev/null @@ -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{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{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&&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;t0&&--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;s0)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();try{for(let s=this.subs;s;s=s.prevSub)s.sub.notify()&&s.sub.dep.notify()}finally{Ws()}}}function er(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let n=t.deps;n;n=n.nextDep)er(n)}const s=e.dep.subs;s!==e&&(e.prevSub=s,s&&(s.nextSub=e)),e.dep.subs=e}}const Es=new WeakMap,Ze=Symbol(""),Cs=Symbol(""),Ot=Symbol("");function ee(e,t,s){if(he&&K){let n=Es.get(e);n||Es.set(e,n=new Map);let r=n.get(s);r||(n.set(s,r=new Ks),r.map=n,r.key=s),r.track()}}function He(e,t,s,n,r,i){const o=Es.get(e);if(!o){At++;return}const l=f=>{f&&f.trigger()};if(Us(),t==="clear")o.forEach(l);else{const f=R(e),d=f&&js(s);if(f&&s==="length"){const a=Number(n);o.forEach((p,T)=>{(T==="length"||T===Ot||!Me(T)&&T>=a)&&l(p)})}else switch((s!==void 0||o.has(void 0))&&l(o.get(s)),d&&l(o.get(Ot)),t){case"add":f?d&&l(o.get("length")):(l(o.get(Ze)),rt(e)&&l(o.get(Cs)));break;case"delete":f||(l(o.get(Ze)),rt(e)&&l(o.get(Cs)));break;case"set":rt(e)&&l(o.get(Ze));break}}Ws()}function tt(e){const t=j(e);return t===e?t:(ee(t,"iterate",Ot),ue(e)?t:t.map(pe))}function rs(e){return ee(e=j(e),"iterate",Ot),e}function Se(e,t){return $e(e)?ct(Qe(e)?pe(t):t):pe(t)}const fi={__proto__:null,[Symbol.iterator](){return gs(this,Symbol.iterator,e=>Se(this,e))},concat(...e){return tt(this).concat(...e.map(t=>R(t)?tt(t):t))},entries(){return gs(this,"entries",e=>(e[1]=Se(this,e[1]),e))},every(e,t){return Fe(this,"every",e,t,void 0,arguments)},filter(e,t){return Fe(this,"filter",e,t,s=>s.map(n=>Se(this,n)),arguments)},find(e,t){return Fe(this,"find",e,t,s=>Se(this,s),arguments)},findIndex(e,t){return Fe(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Fe(this,"findLast",e,t,s=>Se(this,s),arguments)},findLastIndex(e,t){return Fe(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Fe(this,"forEach",e,t,void 0,arguments)},includes(...e){return ms(this,"includes",e)},indexOf(...e){return ms(this,"indexOf",e)},join(e){return tt(this).join(e)},lastIndexOf(...e){return ms(this,"lastIndexOf",e)},map(e,t){return Fe(this,"map",e,t,void 0,arguments)},pop(){return pt(this,"pop")},push(...e){return pt(this,"push",e)},reduce(e,...t){return un(this,"reduce",e,t)},reduceRight(e,...t){return un(this,"reduceRight",e,t)},shift(){return pt(this,"shift")},some(e,t){return Fe(this,"some",e,t,void 0,arguments)},splice(...e){return pt(this,"splice",e)},toReversed(){return tt(this).toReversed()},toSorted(e){return tt(this).toSorted(e)},toSpliced(...e){return tt(this).toSpliced(...e)},unshift(...e){return pt(this,"unshift",e)},values(){return gs(this,"values",e=>Se(this,e))}};function gs(e,t,s){const n=rs(e),r=n[t]();return n!==e&&!ue(e)&&(r._next=r.next,r.next=()=>{const i=r._next();return i.done||(i.value=s(i.value)),i}),r}const ui=Array.prototype;function Fe(e,t,s,n,r,i){const o=rs(e),l=o!==e&&!ue(e),f=o[t];if(f!==ui[t]){const p=f.apply(e,i);return l?pe(p):p}let d=s;o!==e&&(l?d=function(p,T){return s.call(this,Se(e,p),T,e)}:s.length>2&&(d=function(p,T){return s.call(this,p,T,e)}));const a=f.call(o,d,n);return l&&r?r(a):a}function un(e,t,s,n){const r=rs(e),i=r!==e&&!ue(e);let o=s,l=!1;r!==e&&(i?(l=n.length===0,o=function(d,a,p){return l&&(l=!1,d=Se(e,d)),s.call(this,d,Se(e,a),p,e)}):s.length>3&&(o=function(d,a,p){return s.call(this,d,a,p,e)}));const f=r[t](o,...n);return l?Se(e,f):f}function ms(e,t,s){const n=j(e);ee(n,"iterate",Ot);const r=n[t](...s);return(r===-1||r===!1)&&qs(s[0])?(s[0]=j(s[0]),n[t](...s)):r}function pt(e,t,s=[]){Re(),Us();const n=j(e)[t].apply(e,s);return Ws(),Ie(),n}const ai=Ls("__proto__,__v_isRef,__isVue"),tr=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(Me));function di(e){Me(e)||(e=String(e));const t=j(this);return ee(t,"has",e),t.hasOwnProperty(e)}class sr{constructor(t=!1,s=!1){this._isReadonly=t,this._isShallow=s}get(t,s,n){if(s==="__v_skip")return t.__v_skip;const r=this._isReadonly,i=this._isShallow;if(s==="__v_isReactive")return!r;if(s==="__v_isReadonly")return r;if(s==="__v_isShallow")return i;if(s==="__v_raw")return n===(r?i?wi:or:i?ir:rr).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(n)?t:void 0;const o=R(t);if(!r){let f;if(o&&(f=fi[s]))return f;if(s==="hasOwnProperty")return di}const l=Reflect.get(t,s,te(t)?t:n);if((Me(s)?tr.has(s):ai(s))||(r||ee(t,"get",s),i))return l;if(te(l)){const f=o&&js(s)?l:l.value;return r&&U(f)?Os(f):f}return U(l)?r?Os(l):Gs(l):l}}class nr extends sr{constructor(t=!1){super(!1,t)}set(t,s,n,r){let i=t[s];const o=R(t)&&js(s);if(!this._isShallow){const d=$e(i);if(!ue(n)&&!$e(n)&&(i=j(i),n=j(n)),!o&&te(i)&&!te(n))return d||(i.value=n),!0}const l=o?Number(s)e,Wt=e=>Reflect.getPrototypeOf(e);function _i(e,t,s){return function(...n){const r=this.__v_raw,i=j(r),o=rt(i),l=e==="entries"||e===Symbol.iterator&&o,f=e==="keys"&&o,d=r[e](...n),a=s?As:t?ct:pe;return!t&&ee(i,"iterate",f?Cs:Ze),se(Object.create(d),{next(){const{value:p,done:T}=d.next();return T?{value:p,done:T}:{value:l?[a(p[0]),a(p[1])]:a(p),done:T}}})}}function Bt(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function bi(e,t){const s={get(r){const i=this.__v_raw,o=j(i),l=j(r);e||(Ce(r,l)&&ee(o,"get",r),ee(o,"get",l));const{has:f}=Wt(o),d=t?As:e?ct:pe;if(f.call(o,r))return d(i.get(r));if(f.call(o,l))return d(i.get(l));i!==o&&i.get(r)},get size(){const r=this.__v_raw;return!e&&ee(j(r),"iterate",Ze),r.size},has(r){const i=this.__v_raw,o=j(i),l=j(r);return e||(Ce(r,l)&&ee(o,"has",r),ee(o,"has",l)),r===l?i.has(r):i.has(r)||i.has(l)},forEach(r,i){const o=this,l=o.__v_raw,f=j(l),d=t?As:e?ct:pe;return!e&&ee(f,"iterate",Ze),l.forEach((a,p)=>r.call(i,d(a),d(p),o))}};return se(s,e?{add:Bt("add"),set:Bt("set"),delete:Bt("delete"),clear:Bt("clear")}:{add(r){const i=j(this),o=Wt(i),l=j(r),f=!t&&!ue(r)&&!$e(r)?l:r;return o.has.call(i,f)||Ce(r,f)&&o.has.call(i,r)||Ce(l,f)&&o.has.call(i,l)||(i.add(f),He(i,"add",f,f)),this},set(r,i){!t&&!ue(i)&&!$e(i)&&(i=j(i));const o=j(this),{has:l,get:f}=Wt(o);let d=l.call(o,r);d||(r=j(r),d=l.call(o,r));const a=f.call(o,r);return o.set(r,i),d?Ce(i,a)&&He(o,"set",r,i):He(o,"add",r,i),this},delete(r){const i=j(this),{has:o,get:l}=Wt(i);let f=o.call(i,r);f||(r=j(r),f=o.call(i,r)),l&&l.call(i,r);const d=i.delete(r);return f&&He(i,"delete",r,void 0),d},clear(){const r=j(this),i=r.size!==0,o=r.clear();return i&&He(r,"clear",void 0,void 0),o}}),["keys","values","entries",Symbol.iterator].forEach(r=>{s[r]=_i(r,e,t)}),s}function Vs(e,t){const s=bi(e,t);return(n,r,i)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?n:Reflect.get($(s,r)&&r in n?s:n,r,i)}const vi={get:Vs(!1,!1)},xi={get:Vs(!1,!0)},yi={get:Vs(!0,!1)};const rr=new WeakMap,ir=new WeakMap,or=new WeakMap,wi=new WeakMap;function Ti(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Gs(e){return $e(e)?e:ks(e,!1,pi,vi,rr)}function Si(e){return ks(e,!1,mi,xi,ir)}function Os(e){return ks(e,!0,gi,yi,or)}function ks(e,t,s,n,r){if(!U(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;const i=r.get(e);if(i)return i;const o=Ti(Jr(e));if(o===0)return e;const l=new Proxy(e,o===2?n:s);return r.set(e,l),l}function Qe(e){return $e(e)?Qe(e.__v_raw):!!(e&&e.__v_isReactive)}function $e(e){return!!(e&&e.__v_isReadonly)}function ue(e){return!!(e&&e.__v_isShallow)}function qs(e){return e?!!e.__v_raw:!1}function j(e){const t=e&&e.__v_raw;return t?j(t):e}function Ei(e){return!$(e,"__v_skip")&&Object.isExtensible(e)&&Kn(e,"__v_skip",!0),e}const pe=e=>U(e)?Gs(e):e,ct=e=>U(e)?Os(e):e;function te(e){return e?e.__v_isRef===!0:!1}function mt(e){return Ci(e,!1)}function Ci(e,t){return te(e)?e:new Ai(e,t)}class Ai{constructor(t,s){this.dep=new Ks,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=s?t:j(t),this._value=s?t:pe(t),this.__v_isShallow=s}get value(){return this.dep.track(),this._value}set value(t){const s=this._rawValue,n=this.__v_isShallow||ue(t)||$e(t);t=n?t:j(t),Ce(t,s)&&(this._rawValue=t,this._value=n?t:pe(t),this.dep.trigger())}}function _t(e){return te(e)?e.value:e}const Oi={get:(e,t,s)=>t==="__v_raw"?e:_t(Reflect.get(e,t,s)),set:(e,t,s,n)=>{const r=e[t];return te(r)&&!te(s)?(r.value=s,!0):Reflect.set(e,t,s,n)}};function lr(e){return Qe(e)?e:new Proxy(e,Oi)}class Pi{constructor(t,s,n){this.fn=t,this.setter=s,this._value=void 0,this.dep=new Ks(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=At-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!s,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&K!==this)return Yn(this,!0),!0}get value(){const t=this.dep.track();return Zn(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Mi(e,t,s=!1){let n,r;return F(e)?n=e:(n=e.get,r=e.set),new Pi(n,r,s)}const Kt={},qt=new WeakMap;let Xe;function Ri(e,t=!1,s=Xe){if(s){let n=qt.get(s);n||qt.set(s,n=[]),n.push(e)}}function Ii(e,t,s=V){const{immediate:n,deep:r,once:i,scheduler:o,augmentJob:l,call:f}=s,d=O=>r?O:ue(O)||r===!1||r===0?Be(O,1):Be(O);let a,p,T,S,H=!1,M=!1;if(te(e)?(p=()=>e.value,H=ue(e)):Qe(e)?(p=()=>d(e),H=!0):R(e)?(M=!0,H=e.some(O=>Qe(O)||ue(O)),p=()=>e.map(O=>{if(te(O))return O.value;if(Qe(O))return d(O);if(F(O))return f?f(O,2):O()})):F(e)?t?p=f?()=>f(e,2):e:p=()=>{if(T){Re();try{T()}finally{Ie()}}const O=Xe;Xe=a;try{return f?f(e,3,[S]):e(S)}finally{Xe=O}}:p=Pe,t&&r){const O=p,z=r===!0?1/0:r;p=()=>Be(O(),z)}const k=oi(),C=()=>{a.stop(),k&&k.active&&Hs(k.effects,a)};if(i&&t){const O=t;t=(...z)=>{const me=O(...z);return C(),me}}let D=M?new Array(e.length).fill(Kt):Kt;const G=O=>{if(!(!(a.flags&1)||!a.dirty&&!O))if(t){const z=a.run();if(O||r||H||(M?z.some((me,_e)=>Ce(me,D[_e])):Ce(z,D))){T&&T();const me=Xe;Xe=a;try{const _e=[z,D===Kt?void 0:M&&D[0]===Kt?[]:D,S];D=z,f?f(t,3,_e):t(..._e)}finally{Xe=me}}}else a.run()};return l&&l(G),a=new qn(p),a.scheduler=o?()=>o(G,!1):G,S=O=>Ri(O,!1,a),T=a.onStop=()=>{const O=qt.get(a);if(O){if(f)f(O,4);else for(const z of O)z();qt.delete(a)}},t?n?G(!0):D=a.run():o?o(G.bind(null,!0),!0):a.run(),C.pause=a.pause.bind(a),C.resume=a.resume.bind(a),C.stop=C,C}function Be(e,t=1/0,s){if(t<=0||!U(e)||e.__v_skip||(s=s||new Map,(s.get(e)||0)>=t))return e;if(s.set(e,t),t--,te(e))Be(e.value,t,s);else if(R(e))for(let n=0;n{Be(n,t,s)});else if(Wn(e)){for(const n in e)Be(e[n],t,s);for(const n of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,n)&&Be(e[n],t,s)}return e}/** -* @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>>1,r=ie[n],i=Pt(r);i=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(;sPt(s)-Pt(n));if(it.length=0,We){We.push(...t);return}for(We=t,st=0;ste.id==null?e.flags&2?-1:1/0:e.id;function ar(e){try{for(Te=0;Te{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;o1)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;re.__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.k&&(a[e.k]=o))};if(o){const D=()=>{C(),zt.delete(e)};D.id=-1,zt.set(e,D),le(D,s)}else hn(e),C()}}}function hn(e){const t=zt.get(e);t&&(t.flags|=8,zt.delete(e))}ns().requestIdleCallback;ns().cancelIdleCallback;const St=e=>!!e.type.__asyncLoader,mr=e=>e.type.__isKeepAlive;function Vi(e,t){_r(e,"a",t)}function Gi(e,t){_r(e,"da",t)}function _r(e,t,s=oe){const n=e.__wdc||(e.__wdc=()=>{let r=s;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(os(t,n,s),s){let r=s.parent;for(;r&&r.parent;)mr(r.parent.vnode)&&ki(n,t,s,r),r=r.parent}}function ki(e,t,s,n){const r=os(t,e,n,!0);zs(()=>{Hs(n[t],r)},s)}function os(e,t,s=oe,n=!1){if(s){const r=s[e]||(s[e]=[]),i=t.__weh||(t.__weh=(...o)=>{Re();const l=Lt(s),f=ge(t,s,e,o);return l(),Ie(),f});return n?r.unshift(i):r.push(i),i}}const Ne=e=>(t,s=oe)=>{(!Rt||e==="sp")&&os(e,(...n)=>t(...n),s)},qi=Ne("bm"),br=Ne("m"),Ji=Ne("bu"),Yi=Ne("u"),zi=Ne("bum"),zs=Ne("um"),Xi=Ne("sp"),Zi=Ne("rtg"),Qi=Ne("rtc");function eo(e,t=oe){os("ec",e,t)}const to=Symbol.for("v-ndc");function so(e,t,s,n){let r;const i=s,o=R(e);if(o||J(e)){const l=o&&Qe(e);let f=!1,d=!1;l&&(f=!ue(e),d=$e(e),e=rs(e)),r=new Array(e.length);for(let a=0,p=e.length;at(l,f,void 0,i));else{const l=Object.keys(e);r=new Array(l.length);for(let f=0,d=l.length;fe?Nr(e)?Qs(e):Ps(e.parent):null,Et=se(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Ps(e.parent),$root:e=>Ps(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>xr(e),$forceUpdate:e=>e.f||(e.f=()=>{Js(e.update)}),$nextTick:e=>e.n||(e.n=Di.bind(e.proxy)),$watch:e=>Wi.bind(e)}),vs=(e,t)=>e!==V&&!e.__isScriptSetup&&$(e,t),no={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:s,setupState:n,data:r,props:i,accessCache:o,type:l,appContext:f}=e;if(t[0]!=="$"){const T=o[t];if(T!==void 0)switch(T){case 1:return n[t];case 2:return r[t];case 4:return s[t];case 3:return i[t]}else{if(vs(n,t))return o[t]=1,n[t];if(r!==V&&$(r,t))return o[t]=2,r[t];if($(i,t))return o[t]=3,i[t];if(s!==V&&$(s,t))return o[t]=4,s[t];Ms&&(o[t]=0)}}const d=Et[t];let a,p;if(d)return t==="$attrs"&&ee(e.attrs,"get",""),d(e);if((a=l.__cssModules)&&(a=a[t]))return a;if(s!==V&&$(s,t))return o[t]=4,s[t];if(p=f.config.globalProperties,$(p,t))return p[t]},set({_:e},t,s){const{data:n,setupState:r,ctx:i}=e;return vs(r,t)?(r[t]=s,!0):n!==V&&$(n,t)?(n[t]=s,!0):$(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(i[t]=s,!0)},has({_:{data:e,setupState:t,accessCache:s,ctx:n,appContext:r,props:i,type:o}},l){let f;return!!(s[l]||e!==V&&l[0]!=="$"&&$(e,l)||vs(t,l)||$(i,l)||$(n,l)||$(Et,l)||$(r.config.globalProperties,l)||(f=o.__cssModules)&&f[l])},defineProperty(e,t,s){return s.get!=null?e._.accessCache[t]=0:$(s,"value")&&this.set(e,t,s.value,null),Reflect.defineProperty(e,t,s)}};function pn(e){return R(e)?e.reduce((t,s)=>(t[s]=null,t),{}):e}let Ms=!0;function ro(e){const t=xr(e),s=e.proxy,n=e.ctx;Ms=!1,t.beforeCreate&&gn(t.beforeCreate,e,"bc");const{data:r,computed:i,methods:o,watch:l,provide:f,inject:d,created:a,beforeMount:p,mounted:T,beforeUpdate:S,updated:H,activated:M,deactivated:k,beforeDestroy:C,beforeUnmount:D,destroyed:G,unmounted:O,render:z,renderTracked:me,renderTriggered:_e,errorCaptured:Ue,serverPrefetch:Ht,expose:Ge,inheritAttrs:ut,components:jt,directives:$t,filters:fs}=t;if(d&&io(d,n,null),o)for(const q in o){const B=o[q];F(B)&&(n[q]=B.bind(s))}if(r){const q=r.call(s,s);U(q)&&(e.data=Gs(q))}if(Ms=!0,i)for(const q in i){const B=i[q],ke=F(B)?B.bind(s,s):F(B.get)?B.get.bind(s,s):Pe,Nt=!F(B)&&F(B.set)?B.set.bind(s):Pe,qe=Wr({get:ke,set:Nt});Object.defineProperty(n,q,{enumerable:!0,configurable:!0,get:()=>qe.value,set:be=>qe.value=be})}if(l)for(const q in l)vr(l[q],n,s,q);if(f){const q=F(f)?f.call(s):f;Reflect.ownKeys(q).forEach(B=>{$i(B,q[B])})}a&&gn(a,e,"c");function ne(q,B){R(B)?B.forEach(ke=>q(ke.bind(s))):B&&q(B.bind(s))}if(ne(qi,p),ne(br,T),ne(Ji,S),ne(Yi,H),ne(Vi,M),ne(Gi,k),ne(eo,Ue),ne(Qi,me),ne(Zi,_e),ne(zi,D),ne(zs,O),ne(Xi,Ht),R(Ge))if(Ge.length){const q=e.exposed||(e.exposed={});Ge.forEach(B=>{Object.defineProperty(q,B,{get:()=>s[B],set:ke=>s[B]=ke,enumerable:!0})})}else e.exposed||(e.exposed={});z&&e.render===Pe&&(e.render=z),ut!=null&&(e.inheritAttrs=ut),jt&&(e.components=jt),$t&&(e.directives=$t),Ht&&gr(e)}function io(e,t,s=Pe){R(e)&&(e=Rs(e));for(const n in e){const r=e[n];let i;U(r)?"default"in r?i=Vt(r.from||n,r.default,!0):i=Vt(r.from||n):i=Vt(r),te(i)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>i.value,set:o=>i.value=o}):t[n]=i}}function gn(e,t,s){ge(R(e)?e.map(n=>n.bind(t.proxy)):e.bind(t.proxy),t,s)}function vr(e,t,s,n){let r=n.includes(".")?pr(s,n):()=>s[n];if(J(e)){const i=t[e];F(i)&&_s(r,i)}else if(F(e))_s(r,e.bind(s));else if(U(e))if(R(e))e.forEach(i=>vr(i,t,s,n));else{const i=F(e.handler)?e.handler.bind(s):t[e.handler];F(i)&&_s(r,i,e)}}function xr(e){const t=e.type,{mixins:s,extends:n}=t,{mixins:r,optionsCache:i,config:{optionMergeStrategies:o}}=e.appContext,l=i.get(t);let f;return l?f=l:!r.length&&!s&&!n?f=t:(f={},r.length&&r.forEach(d=>Xt(f,d,o,!0)),Xt(f,t,o)),U(t)&&i.set(t,f),f}function Xt(e,t,s,n=!1){const{mixins:r,extends:i}=t;i&&Xt(e,i,s,!0),r&&r.forEach(o=>Xt(e,o,s,!0));for(const o in t)if(!(n&&o==="expose")){const l=oo[o]||s&&s[o];e[o]=l?l(e[o],t[o]):t[o]}return e}const oo={data:mn,props:_n,emits:_n,methods:bt,computed:bt,beforeCreate:re,created:re,beforeMount:re,mounted:re,beforeUpdate:re,updated:re,beforeDestroy:re,beforeUnmount:re,destroyed:re,unmounted:re,activated:re,deactivated:re,errorCaptured:re,serverPrefetch:re,components:bt,directives:bt,watch:co,provide:mn,inject:lo};function mn(e,t){return t?e?function(){return se(F(e)?e.call(this,this):e,F(t)?t.call(this,this):t)}:t:e}function lo(e,t){return bt(Rs(e),Rs(t))}function Rs(e){if(R(e)){const t={};for(let s=0;st==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${de(t)}Modifiers`]||e[`${et(t)}Modifiers`];function ho(e,t,...s){if(e.isUnmounted)return;const n=e.vnode.props||V;let r=s;const i=t.startsWith("update:"),o=i&&ao(n,t.slice(7));o&&(o.trim&&(r=s.map(a=>J(a)?a.trim():a)),o.number&&(r=s.map(Xr)));let l,f=n[l=as(t)]||n[l=as(de(t))];!f&&i&&(f=n[l=as(et(t))]),f&&ge(f,e,6,r);const d=n[l+"Once"];if(d){if(!e.emitted)e.emitted={};else if(e.emitted[l])return;e.emitted[l]=!0,ge(d,e,6,r)}}const po=new WeakMap;function wr(e,t,s=!1){const n=s?po:t.emitsCache,r=n.get(e);if(r!==void 0)return r;const i=e.emits;let o={},l=!1;if(!F(e)){const f=d=>{const a=wr(d,t,!0);a&&(l=!0,se(o,a))};!s&&t.mixins.length&&t.mixins.forEach(f),e.extends&&f(e.extends),e.mixins&&e.mixins.forEach(f)}return!i&&!l?(U(e)&&n.set(e,null),null):(R(i)?i.forEach(f=>o[f]=null):se(o,i),U(e)&&n.set(e,o),o)}function ls(e,t){return!e||!es(t)?!1:(t=t.slice(2),t=t==="Once"?t:t.replace(/Once$/,""),$(e,t[0].toLowerCase()+t.slice(1))||$(e,et(t))||$(e,t))}function bn(e){const{type:t,vnode:s,proxy:n,withProxy:r,propsOptions:[i],slots:o,attrs:l,emit:f,render:d,renderCache:a,props:p,data:T,setupState:S,ctx:H,inheritAttrs:M}=e,k=Yt(e);let C,D;try{if(s.shapeFlag&4){const O=r||n,z=O;C=Ee(d.call(z,O,a,p,S,T,H)),D=l}else{const O=t;C=Ee(O.length>1?O(p,{attrs:l,slots:o,emit:f}):O(p,null)),D=t.props?l:go(l)}}catch(O){Ct.length=0,is(O,e,1),C=Q(Ve)}let G=C;if(D&&M!==!1){const O=Object.keys(D),{shapeFlag:z}=G;O.length&&z&7&&(i&&O.some(ts)&&(D=mo(D,i)),G=ft(G,D,!1,!0))}return s.dirs&&(G=ft(G,null,!1,!0),G.dirs=G.dirs?G.dirs.concat(s.dirs):s.dirs),s.transition&&Ys(G,s.transition),C=G,Yt(k),C}const go=e=>{let t;for(const s in e)(s==="class"||s==="style"||es(s))&&((t||(t={}))[s]=e[s]);return t},mo=(e,t)=>{const s={};for(const n in e)(!ts(n)||!(n.slice(9)in t))&&(s[n]=e[n]);return s};function _o(e,t,s){const{props:n,children:r,component:i}=e,{props:o,children:l,patchFlag:f}=t,d=i.emitsOptions;if(t.dirs||t.transition)return!0;if(s&&f>=0){if(f&1024)return!0;if(f&16)return n?vn(n,o,d):!!o;if(f&8){const a=t.dynamicProps;for(let p=0;pObject.create(Sr),Cr=e=>Object.getPrototypeOf(e)===Sr;function vo(e,t,s,n=!1){const r={},i=Er();e.propsDefaults=Object.create(null),Ar(e,t,r,i);for(const o in e.propsOptions[0])o in r||(r[o]=void 0);s?e.props=n?r:Si(r):e.type.props?e.props=r:e.props=i,e.attrs=i}function xo(e,t,s,n){const{props:r,attrs:i,vnode:{patchFlag:o}}=e,l=j(r),[f]=e.propsOptions;let d=!1;if((n||o>0)&&!(o&16)){if(o&8){const a=e.vnode.dynamicProps;for(let p=0;p{f=!0;const[T,S]=Or(p,t,!0);se(o,T),S&&l.push(...S)};!s&&t.mixins.length&&t.mixins.forEach(a),e.extends&&a(e.extends),e.mixins&&e.mixins.forEach(a)}if(!i&&!f)return U(e)&&n.set(e,nt),nt;if(R(i))for(let a=0;ae==="_"||e==="_ctx"||e==="$stable",Zs=e=>R(e)?e.map(Ee):[Ee(e)],wo=(e,t,s)=>{if(t._n)return t;const n=ji((...r)=>Zs(t(...r)),s);return n._c=!1,n},Pr=(e,t,s)=>{const n=e._ctx;for(const r in e){if(Xs(r))continue;const i=e[r];if(F(i))t[r]=wo(r,i,n);else if(i!=null){const o=Zs(i);t[r]=()=>o}}},Mr=(e,t)=>{const s=Zs(t);e.slots.default=()=>s},Rr=(e,t,s)=>{for(const n in t)(s||!Xs(n))&&(e[n]=t[n])},To=(e,t,s)=>{const n=e.slots=Er();if(e.vnode.shapeFlag&32){const r=t._;r?(Rr(n,t,s),s&&Kn(n,"_",r,!0)):Pr(t,n)}else t&&Mr(e,t)},So=(e,t,s)=>{const{vnode:n,slots:r}=e;let i=!0,o=V;if(n.shapeFlag&32){const l=t._;l?s&&l===1?i=!1:Rr(r,t,s):(i=!t.$stable,Pr(t,r)),o=t}else t&&(Mr(e,t),o={default:1});if(i)for(const l in r)!Xs(l)&&o[l]==null&&delete r[l]},le=Po;function Eo(e){return Co(e)}function Co(e,t){const s=ns();s.__VUE__=!0;const{insert:n,remove:r,patchProp:i,createElement:o,createText:l,createComment:f,setText:d,setElementText:a,parentNode:p,nextSibling:T,setScopeId:S=Pe,insertStaticContent:H}=e,M=(c,u,h,b=null,_=null,g=null,y=void 0,x=null,v=!!u.dynamicChildren)=>{if(c===u)return;c&&!gt(c,u)&&(b=Ut(c),be(c,_,g,!0),c=null),u.patchFlag===-2&&(v=!1,u.dynamicChildren=null);const{type:m,ref:A,shapeFlag:w}=u;switch(m){case cs:k(c,u,h,b);break;case Ve:C(c,u,h,b);break;case ys:c==null&&D(u,h,b,y);break;case ae:jt(c,u,h,b,_,g,y,x,v);break;default:w&1?z(c,u,h,b,_,g,y,x,v):w&6?$t(c,u,h,b,_,g,y,x,v):(w&64||w&128)&&m.process(c,u,h,b,_,g,y,x,v,dt)}A!=null&&_?Tt(A,c&&c.ref,g,u||c,!u):A==null&&c&&c.ref!=null&&Tt(c.ref,null,g,c,!0)},k=(c,u,h,b)=>{if(c==null)n(u.el=l(u.children),h,b);else{const _=u.el=c.el;u.children!==c.children&&d(_,u.children)}},C=(c,u,h,b)=>{c==null?n(u.el=f(u.children||""),h,b):u.el=c.el},D=(c,u,h,b)=>{[c.el,c.anchor]=H(c.children,u,h,b,c.el,c.anchor)},G=({el:c,anchor:u},h,b)=>{let _;for(;c&&c!==u;)_=T(c),n(c,h,b),c=_;n(u,h,b)},O=({el:c,anchor:u})=>{let h;for(;c&&c!==u;)h=T(c),r(c),c=h;r(u)},z=(c,u,h,b,_,g,y,x,v)=>{if(u.type==="svg"?y="svg":u.type==="math"&&(y="mathml"),c==null)me(u,h,b,_,g,y,x,v);else{const m=c.el&&c.el._isVueCE?c.el:null;try{m&&m._beginPatch(),Ht(c,u,_,g,y,x,v)}finally{m&&m._endPatch()}}},me=(c,u,h,b,_,g,y,x)=>{let v,m;const{props:A,shapeFlag:w,transition:E,dirs:P}=c;if(v=c.el=o(c.type,g,A&&A.is,A),w&8?a(v,c.children):w&16&&Ue(c.children,v,null,b,_,xs(c,g),y,x),P&&Je(c,null,b,"created"),_e(v,c,c.scopeId,y,b),A){for(const W in A)W!=="value"&&!xt(W)&&i(v,W,null,A[W],g,b);"value"in A&&i(v,"value",null,A.value,g),(m=A.onVnodeBeforeMount)&&we(m,b,c)}P&&Je(c,null,b,"beforeMount");const L=Ao(_,E);L&&E.beforeEnter(v),n(v,u,h),((m=A&&A.onVnodeMounted)||L||P)&&le(()=>{try{m&&we(m,b,c),L&&E.enter(v),P&&Je(c,null,b,"mounted")}finally{}},_)},_e=(c,u,h,b,_)=>{if(h&&S(c,h),b)for(let g=0;g{for(let m=v;m{const x=u.el=c.el;let{patchFlag:v,dynamicChildren:m,dirs:A}=u;v|=c.patchFlag&16;const w=c.props||V,E=u.props||V;let P;if(h&&Ye(h,!1),(P=E.onVnodeBeforeUpdate)&&we(P,h,u,c),A&&Je(u,c,h,"beforeUpdate"),h&&Ye(h,!0),m&&(!c.dynamicChildren||c.dynamicChildren.length!==m.length)&&(v=0,y=!1,m=null),(w.innerHTML&&E.innerHTML==null||w.textContent&&E.textContent==null)&&a(x,""),m?Ge(c.dynamicChildren,m,x,h,b,xs(u,_),g):y||B(c,u,x,null,h,b,xs(u,_),g,!1),v>0){if(v&16)ut(x,w,E,h,_);else if(v&2&&w.class!==E.class&&i(x,"class",null,E.class,_),v&4&&i(x,"style",w.style,E.style,_),v&8){const L=u.dynamicProps;for(let W=0;W{P&&we(P,h,u,c),A&&Je(u,c,h,"updated")},b)},Ge=(c,u,h,b,_,g,y)=>{for(let x=0;x{if(u!==h){if(u!==V)for(const g in u)!xt(g)&&!(g in h)&&i(c,g,u[g],null,_,b);for(const g in h){if(xt(g))continue;const y=h[g],x=u[g];y!==x&&g!=="value"&&i(c,g,x,y,_,b)}"value"in h&&i(c,"value",u.value,h.value,_)}},jt=(c,u,h,b,_,g,y,x,v)=>{const m=u.el=c?c.el:l(""),A=u.anchor=c?c.anchor:l("");let{patchFlag:w,dynamicChildren:E,slotScopeIds:P}=u;P&&(x=x?x.concat(P):P),c==null?(n(m,h,b),n(A,h,b),Ue(u.children||[],h,A,_,g,y,x,v)):w>0&&w&64&&E&&c.dynamicChildren&&c.dynamicChildren.length===E.length?(Ge(c.dynamicChildren,E,h,_,g,y,x),(u.key!=null||_&&u===_.subTree)&&Ir(c,u,!0)):B(c,u,h,A,_,g,y,x,v)},$t=(c,u,h,b,_,g,y,x,v)=>{u.slotScopeIds=x,c==null?u.shapeFlag&512?_.ctx.activate(u,h,b,y,v):fs(u,h,b,_,g,y,v):en(c,u,v)},fs=(c,u,h,b,_,g,y)=>{const x=c.component=$o(c,b,_);if(mr(c)&&(x.ctx.renderer=dt),Uo(x,!1,y),x.asyncDep){if(_&&_.registerDep(x,ne,y),!c.el){const v=x.subTree=Q(Ve);C(null,v,u,h),c.placeholder=v.el}}else ne(x,c,u,h,_,g,y)},en=(c,u,h)=>{const b=u.component=c.component;if(_o(c,u,h))if(b.asyncDep&&!b.asyncResolved){q(b,u,h);return}else b.next=u,b.update();else u.el=c.el,b.vnode=u},ne=(c,u,h,b,_,g,y)=>{const x=()=>{if(c.isMounted){let{next:w,bu:E,u:P,parent:L,vnode:W}=c;{const xe=Fr(c);if(xe){w&&(w.el=W.el,q(c,w,y)),xe.asyncDep.then(()=>{le(()=>{c.isUnmounted||m()},_)});return}}let N=w,Y;Ye(c,!1),w?(w.el=W.el,q(c,w,y)):w=W,E&&ds(E),(Y=w.props&&w.props.onVnodeBeforeUpdate)&&we(Y,L,w,W),Ye(c,!0);const X=bn(c),ve=c.subTree;c.subTree=X,M(ve,X,p(ve.el),Ut(ve),c,_,g),w.el=X.el,N===null&&bo(c,X.el),P&&le(P,_),(Y=w.props&&w.props.onVnodeUpdated)&&le(()=>we(Y,L,w,W),_)}else{let w;const{el:E,props:P}=u,{bm:L,m:W,parent:N,root:Y,type:X}=c,ve=St(u);Ye(c,!1),L&&ds(L),!ve&&(w=P&&P.onVnodeBeforeMount)&&we(w,N,u),Ye(c,!0);{Y.ce&&Y.ce._hasShadowRoot()&&Y.ce._injectChildStyle(X,c.parent?c.parent.type:void 0);const xe=c.subTree=bn(c);M(null,xe,h,b,c,_,g),u.el=xe.el}if(W&&le(W,_),!ve&&(w=P&&P.onVnodeMounted)){const xe=u;le(()=>we(w,N,xe),_)}(u.shapeFlag&256||N&&St(N.vnode)&&N.vnode.shapeFlag&256)&&c.a&&le(c.a,_),c.isMounted=!0,u=h=b=null}};c.scope.on();const v=c.effect=new qn(x);c.scope.off();const m=c.update=v.run.bind(v),A=c.job=v.runIfDirty.bind(v);A.i=c,A.id=c.uid,v.scheduler=()=>Js(A),Ye(c,!0),m()},q=(c,u,h)=>{u.component=c;const b=c.vnode.props;c.vnode=u,c.next=null,xo(c,u.props,b,h),So(c,u.children,h),Re(),an(c),Ie()},B=(c,u,h,b,_,g,y,x,v=!1)=>{const m=c&&c.children,A=c?c.shapeFlag:0,w=u.children,{patchFlag:E,shapeFlag:P}=u;if(E>0){if(E&128){Nt(m,w,h,b,_,g,y,x,v);return}else if(E&256){ke(m,w,h,b,_,g,y,x,v);return}}P&8?(A&16&&at(m,_,g),w!==m&&a(h,w)):A&16?P&16?Nt(m,w,h,b,_,g,y,x,v):at(m,_,g,!0):(A&8&&a(h,""),P&16&&Ue(w,h,b,_,g,y,x,v))},ke=(c,u,h,b,_,g,y,x,v)=>{c=c||nt,u=u||nt;const m=c.length,A=u.length,w=Math.min(m,A);let E;for(E=0;EA?at(c,_,g,!0,!1,w):Ue(u,h,b,_,g,y,x,v,w)},Nt=(c,u,h,b,_,g,y,x,v)=>{let m=0;const A=u.length;let w=c.length-1,E=A-1;for(;m<=w&&m<=E;){const P=c[m],L=u[m]=v?Le(u[m]):Ee(u[m]);if(gt(P,L))M(P,L,h,null,_,g,y,x,v);else break;m++}for(;m<=w&&m<=E;){const P=c[w],L=u[E]=v?Le(u[E]):Ee(u[E]);if(gt(P,L))M(P,L,h,null,_,g,y,x,v);else break;w--,E--}if(m>w){if(m<=E){const P=E+1,L=PE)for(;m<=w;)be(c[m],_,g,!0),m++;else{const P=m,L=m,W=new Map;for(m=L;m<=E;m++){const ce=u[m]=v?Le(u[m]):Ee(u[m]);ce.key!=null&&W.set(ce.key,m)}let N,Y=0;const X=E-L+1;let ve=!1,xe=0;const ht=new Array(X);for(m=0;m=X){be(ce,_,g,!0);continue}let ye;if(ce.key!=null)ye=W.get(ce.key);else for(N=L;N<=E;N++)if(ht[N-L]===0&>(ce,u[N])){ye=N;break}ye===void 0?be(ce,_,g,!0):(ht[ye-L]=m+1,ye>=xe?xe=ye:ve=!0,M(ce,u[ye],h,null,_,g,y,x,v),Y++)}const nn=ve?Oo(ht):nt;for(N=nn.length-1,m=X-1;m>=0;m--){const ce=L+m,ye=u[ce],rn=u[ce+1],on=ce+1{const{el:g,type:y,transition:x,children:v,shapeFlag:m}=c;if(m&6){qe(c.component.subTree,u,h,b);return}if(m&128){c.suspense.move(u,h,b);return}if(m&64){y.move(c,u,h,dt);return}if(y===ae){n(g,u,h);for(let w=0;wx.enter(g),_));else{const{leave:w,delayLeave:E,afterLeave:P}=x,L=()=>{c.ctx.isUnmounted?r(g):n(g,u,h)},W=()=>{const N=g._isLeaving||!!g[bs];g._isLeaving&&g[bs](!0),x.persisted&&!N?L():w(g,()=>{L(),P&&P()})};E?E(g,L,W):W()}else n(g,u,h)},be=(c,u,h,b=!1,_=!1)=>{const{type:g,props:y,ref:x,children:v,dynamicChildren:m,shapeFlag:A,patchFlag:w,dirs:E,cacheIndex:P,memo:L}=c;if(w===-2&&(_=!1),x!=null&&(Re(),Tt(x,null,h,c,!0),Ie()),P!=null&&(u.renderCache[P]=void 0),A&256){u.ctx.deactivate(c);return}const W=A&1&&E,N=!St(c);let Y;if(N&&(Y=y&&y.onVnodeBeforeUnmount)&&we(Y,u,c),A&6)kr(c.component,h,b);else{if(A&128){c.suspense.unmount(h,b);return}W&&Je(c,null,u,"beforeUnmount"),A&64?c.type.remove(c,u,h,dt,b):m&&!m.hasOnce&&(g!==ae||w>0&&w&64)?at(m,u,h,!1,!0):(g===ae&&w&384||!_&&A&16)&&at(v,u,h),b&&tn(c)}const X=L!=null&&P==null;(N&&(Y=y&&y.onVnodeUnmounted)||W||X)&&le(()=>{Y&&we(Y,u,c),W&&Je(c,null,u,"unmounted"),X&&(c.el=null)},h)},tn=c=>{const{type:u,el:h,anchor:b,transition:_}=c;if(u===ae){Gr(h,b);return}if(u===ys){O(c);return}const g=()=>{r(h),_&&!_.persisted&&_.afterLeave&&_.afterLeave()};if(c.shapeFlag&1&&_&&!_.persisted){const{leave:y,delayLeave:x}=_,v=()=>y(h,g);x?x(c.el,g,v):v()}else g()},Gr=(c,u)=>{let h;for(;c!==u;)h=T(c),r(c),c=h;r(u)},kr=(c,u,h)=>{const{bum:b,scope:_,job:g,subTree:y,um:x,m:v,a:m}=c;yn(v),yn(m),b&&ds(b),_.stop(),g&&(g.flags|=8,be(y,c,u,h)),x&&le(x,u),le(()=>{c.isUnmounted=!0},u)},at=(c,u,h,b=!1,_=!1,g=0)=>{for(let y=g;y{if(c.shapeFlag&6)return Ut(c.component.subTree);if(c.shapeFlag&128)return c.suspense.next();const u=T(c.anchor||c.el),h=u&&u[Bi];return h?T(h):u};let us=!1;const sn=(c,u,h)=>{let b;c==null?u._vnode&&(be(u._vnode,null,null,!0),b=u._vnode.component):M(u._vnode||null,c,u,null,null,null,h),u._vnode=c,us||(us=!0,an(b),ur(),us=!1)},dt={p:M,um:be,m:qe,r:tn,mt:fs,mc:Ue,pc:B,pbc:Ge,n:Ut,o:e};return{render:sn,hydrate:void 0,createApp:uo(sn)}}function xs({type:e,props:t},s){return s==="svg"&&e==="foreignObject"||s==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:s}function Ye({effect:e,job:t},s){s?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function Ao(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function Ir(e,t,s=!1){const n=e.children,r=t.children;if(R(n)&&R(r))for(let i=0;i>1,e[s[l]]0&&(t[n]=s[i-1]),s[i]=n)}}for(i=s.length,o=s[i-1];i-- >0;)s[i]=o,o=t[o];return s}function Fr(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:Fr(t)}function yn(e){if(e)for(let t=0;te.__isSuspense;function Po(e,t){t&&t.pendingBranch?R(e)?t.effects.push(...e):t.effects.push(e):Hi(e)}const ae=Symbol.for("v-fgt"),cs=Symbol.for("v-txt"),Ve=Symbol.for("v-cmt"),ys=Symbol.for("v-stc"),Ct=[];let fe=null;function je(e=!1){Ct.push(fe=e?null:[])}function Mo(){Ct.pop(),fe=Ct[Ct.length-1]||null}let Mt=1;function wn(e,t=!1){Mt+=e,e<0&&fe&&t&&(fe.hasOnce=!0)}function Hr(e){return e.dynamicChildren=Mt>0?fe||nt:null,Mo(),Mt>0&&fe&&fe.push(e),e}function Ke(e,t,s,n,r,i){return Hr(I(e,t,s,n,r,i,!0))}function Ro(e,t,s,n,r){return Hr(Q(e,t,s,n,r,!0))}function jr(e){return e?e.__v_isVNode===!0:!1}function gt(e,t){return e.type===t.type&&e.key===t.key}const $r=({key:e})=>e??null,Gt=({ref:e,ref_key:t,ref_for:s})=>(typeof e=="number"&&(e=""+e),e!=null?J(e)||te(e)||F(e)?{i:Oe,r:e,k:t,f:!!s}:e:null);function I(e,t=null,s=null,n=0,r=null,i=e===ae?0:1,o=!1,l=!1){const f={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&$r(t),ref:t&&Gt(t),scopeId:dr,slotScopeIds:null,children:s,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:i,patchFlag:n,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:Oe};return l?(Zt(f,s),i&128&&e.normalize(f)):s&&(f.shapeFlag|=J(s)?8:16),Mt>0&&!o&&fe&&(f.patchFlag>0||i&6)&&f.patchFlag!==32&&fe.push(f),f}const Q=Io;function Io(e,t=null,s=null,n=0,r=null,i=!1){if((!e||e===to)&&(e=Ve),jr(e)){const l=ft(e,t,!0);return s&&Zt(l,s),Mt>0&&!i&&fe&&(l.shapeFlag&6?fe[fe.indexOf(e)]=l:fe.push(l)),l.patchFlag=-2,l}if(Vo(e)&&(e=e.__vccOpts),t){t=Fo(t);let{class:l,style:f}=t;l&&!J(l)&&(t.class=Ft(l)),U(f)&&(qs(f)&&!R(f)&&(f=se({},f)),t.style=$s(f))}const o=J(e)?1:Lr(e)?128:Ki(e)?64:U(e)?4:F(e)?2:0;return I(e,t,s,n,r,o,i,!0)}function Fo(e){return e?qs(e)||Cr(e)?se({},e):e:null}function ft(e,t,s=!1,n=!1){const{props:r,ref:i,patchFlag:o,children:l,transition:f}=e,d=t?Lo(r||{},t):r,a={__v_isVNode:!0,__v_skip:!0,type:e.type,props:d,key:d&&$r(d),ref:t&&t.ref?s&&i?R(i)?i.concat(Gt(t)):[i,Gt(t)]:Gt(t):i,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:l,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==ae?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:f,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&ft(e.ssContent),ssFallback:e.ssFallback&&ft(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return f&&n&&Ys(a,f.clone(a)),a}function kt(e=" ",t=0){return Q(cs,null,e,t)}function Do(e="",t=!1){return t?(je(),Ro(Ve,null,e)):Q(Ve,null,e)}function Ee(e){return e==null||typeof e=="boolean"?Q(Ve):R(e)?Q(ae,null,e.slice()):jr(e)?Le(e):Q(cs,null,String(e))}function Le(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:ft(e)}function Zt(e,t){let s=0;const{shapeFlag:n}=e;if(t==null)t=null;else if(R(t))s=16;else if(typeof t=="object")if(n&65){const r=t.default;r&&(r._c&&(r._d=!1),Zt(e,r()),r._c&&(r._d=!0));return}else{s=32;const r=t._;!r&&!Cr(t)?t._ctx=Oe:r===3&&Oe&&(Oe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else if(F(t)){if(n&65){Zt(e,{default:t});return}t={default:t,_ctx:Oe},s=32}else t=String(t),n&64?(s=16,t=[kt(t)]):s=8;e.children=t,e.shapeFlag|=s}function Lo(...e){const t={};for(let s=0;soe||Oe;let Qt,Fs;{const e=ns(),t=(s,n)=>{let r;return(r=e[s])||(r=e[s]=[]),r.push(n),i=>{r.length>1?r.forEach(o=>o(i)):r[0](i)}};Qt=t("__VUE_INSTANCE_SETTERS__",s=>oe=s),Fs=t("__VUE_SSR_SETTERS__",s=>Rt=s)}const Lt=e=>{const t=oe;return Qt(e),e.scope.on(),()=>{e.scope.off(),Qt(t)}},Tn=()=>{oe&&oe.scope.off(),Qt(null)};function Nr(e){return e.vnode.shapeFlag&4}let Rt=!1;function Uo(e,t=!1,s=!1){t&&Fs(t);const{props:n,children:r}=e.vnode,i=Nr(e);vo(e,n,i,t),To(e,r,s||t);const o=i?Wo(e,t):void 0;return t&&Fs(!1),o}function Wo(e,t){const s=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,no);const{setup:n}=s;if(n){Re();const r=e.setupContext=n.length>1?Ko(e):null,i=Lt(e),o=Dt(n,e,0,[e.props,r]),l=Nn(o);if(Ie(),i(),(l||e.sp)&&!St(e)&&gr(e),l){if(o.then(Tn,Tn),t)return o.then(f=>{Sn(e,f)}).catch(f=>{is(f,e,0)});e.asyncDep=o}else Sn(e,o)}else Ur(e)}function Sn(e,t,s){F(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:U(t)&&(e.setupState=lr(t)),Ur(e)}function Ur(e,t,s){const n=e.type;e.render||(e.render=n.render||Pe);{const r=Lt(e);Re();try{ro(e)}finally{Ie(),r()}}}const Bo={get(e,t){return ee(e,"get",""),e[t]}};function Ko(e){const t=s=>{e.exposed=s||{}};return{attrs:new Proxy(e.attrs,Bo),slots:e.slots,emit:e.emit,expose:t}}function Qs(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy(lr(Ei(e.exposed)),{get(t,s){if(s in t)return t[s];if(s in Et)return Et[s](e)},has(t,s){return s in t||s in Et}})):e.proxy}function Vo(e){return F(e)&&"__vccOpts"in e}const Wr=(e,t)=>Mi(e,t,Rt),Go="3.5.39";/** -* @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"?`${e}`:n==="mathml"?`${e}`: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;rTs||(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;fe.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._falseValue=n),Rn(e,t,n,o))};function hl(e,t,s,n){if(n)return!!(t==="innerHTML"||t==="textContent"||t in e&&Dn(t)&&F(s));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return Dn(t)&&J(s)?!1:t in e}function pl(e,t){const s=e._def.props;if(!s)return!1;const n=de(t);return Array.isArray(s)?s.some(r=>de(r)===n):Object.keys(s).some(r=>de(r)===n)}const gl=se({patchProp:dl},Jo);let Ln;function ml(){return Ln||(Ln=Eo(gl))}const _l=((...e)=>{const t=ml().createApp(...e),{mount:s}=t;return t.mount=n=>{const r=vl(n);if(!r)return;const i=t._component;!F(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const o=s(r,!1,bl(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),o},t});function bl(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function vl(e){return J(e)?document.querySelector(e):e}const Kr="dh-panel-theme";function xl(){var e;try{const t=localStorage.getItem(Kr);if(t==="dark"||t==="light")return t}catch{}return(e=window.matchMedia)!=null&&e.call(window,"(prefers-color-scheme: dark)").matches?"dark":"light"}const lt=mt(xl());function Vr(e){lt.value=e,document.documentElement.classList.toggle("dark",e==="dark");try{localStorage.setItem(Kr,e)}catch{}}function Hn(){Vr(lt.value==="dark"?"light":"dark")}Vr(lt.value);const yl={class:"dh-card overflow-hidden"},wl={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Tl={class:"text-base font-bold tracking-[-0.02em] text-strong"},Sl={class:"eyebrow"},El={class:"w-full text-left text-sm"},Cl={class:"data border-t border-subtle px-5 py-2.5 text-xs whitespace-nowrap"},Al={class:"text-strong"},Ol={class:"border-t border-subtle px-5 py-2.5 text-body"},ze={__name:"EndpointTable",props:{title:String,auth:String,endpoints:Array},setup(e){const t={GET:"text-success",POST:"text-brandtext",PATCH:"text-warning",DELETE:"text-danger"};return(s,n)=>(je(),Ke("div",yl,[I("div",wl,[I("div",Tl,Ae(e.title),1),I("span",Sl,Ae(e.auth),1)]),I("table",El,[n[0]||(n[0]=I("thead",null,[I("tr",{class:"[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium"},[I("th",null,"Endpoint"),I("th",null,"Description")])],-1)),I("tbody",null,[(je(!0),Ke(ae,null,so(e.endpoints,r=>(je(),Ke("tr",{key:r.method+r.path,class:"transition-colors hover:bg-sunken"},[I("td",Cl,[I("span",{class:Ft(["font-semibold",t[r.method]])},Ae(r.method),3),I("span",Al,Ae(r.path),1)]),I("td",Ol,Ae(r.desc),1)]))),128))])])]))}},Pl={class:"mx-auto flex max-w-3xl flex-col gap-6 px-6 pt-12 pb-16"},Ml={class:"flex items-center gap-3"},Rl={class:"inline-flex items-center gap-2.5 select-none"},Il={class:"h-8 w-8 shrink-0",viewBox:"0 0 48 48",fill:"none","aria-hidden":"true"},Fl={transform:"translate(7 0) skewX(-13)"},Dl=["fill"],Ll=["fill"],Hl=["fill"],jl=["title"],$l={key:0,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"},Nl={key:1,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"},Ul={class:"dh-card"},Wl={class:"flex items-center justify-between border-b border-subtle px-5 py-4"},Bl={class:"data flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4 text-xs text-muted"},Kl={__name:"App",setup(e){const t=mt("checking"),s=mt(null),n=mt(null),r=mt(null);let i=null;async function o(){const k=performance.now();try{const C=await fetch("/api/health");n.value=Math.round(performance.now()-k),s.value=C.status,t.value=C.ok?"ok":"error"}catch{n.value=null,s.value=null,t.value="unreachable"}r.value=new Date}br(()=>{o(),i=setInterval(o,1e4)}),zs(()=>clearInterval(i));const l={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"}},f=Wr(()=>lt.value==="dark"?["#60a5fa","#93c5fd","#ffffff"]:["var(--brand-700)","var(--brand-500)","var(--brand-400)"]),d=[{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/auth/me",desc:"Identity of the bearer token"}],a=[{method:"GET",path:"/api/cars",desc:"List owned + shared cars"},{method:"POST",path:"/api/cars",desc:"Create a car"},{method:"GET",path:"/api/cars/{id}",desc:"Fetch one car"},{method:"PATCH",path:"/api/cars/{id}",desc:"Update a car"},{method:"DELETE",path:"/api/cars/{id}",desc:"Delete a car (owner only)"},{method:"GET",path:"/api/cars/{id}/service-records",desc:"A car's service history"},{method:"GET",path:"/api/cars/{id}/parts",desc:"A car's parts catalog"},{method:"GET",path:"/api/cars/{id}/shares",desc:"Who a car is shared with (owner)"},{method:"POST",path:"/api/cars/{id}/shares",desc:"Share a car by email (owner)"},{method:"DELETE",path:"/api/cars/{id}/shares/{userId}",desc:"Revoke a share (owner)"}],p=[{method:"GET",path:"/api/service-records",desc:"List service records"},{method:"POST",path:"/api/service-records",desc:"Log a service record"},{method:"GET",path:"/api/service-records/{id}",desc:"Fetch one record"},{method:"PATCH",path:"/api/service-records/{id}",desc:"Update a record"},{method:"DELETE",path:"/api/service-records/{id}",desc:"Delete a record"}],T=[{method:"GET",path:"/api/parts",desc:"List parts"},{method:"POST",path:"/api/parts",desc:"Add a part"},{method:"GET",path:"/api/parts/{id}",desc:"Fetch one part"},{method:"PATCH",path:"/api/parts/{id}",desc:"Update a part"},{method:"DELETE",path:"/api/parts/{id}",desc:"Delete a part"}],S=[{method:"GET",path:"/api/me",desc:"Current user profile"},{method:"PATCH",path:"/api/me",desc:"Update profile"},{method:"POST",path:"/api/me/password",desc:"Change password"},{method:"POST",path:"/api/me/avatar",desc:"Upload avatar"},{method:"GET",path:"/api/me/avatar",desc:"Fetch avatar"},{method:"DELETE",path:"/api/me/avatar",desc:"Remove avatar"},{method:"POST",path:"/api/me/verify/request",desc:"Request email verification"},{method:"GET",path:"/api/me/export",desc:"Export your data"},{method:"POST",path:"/api/me/import",desc:"Import data"},{method:"POST",path:"/api/me/delete",desc:"Request account deletion"},{method:"POST",path:"/api/me/delete/cancel",desc:"Cancel deletion request"},{method:"DELETE",path:"/api/me",desc:"Finalize account deletion"}],H=[{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"}],M=[{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"}];return(k,C)=>(je(),Ke("div",Pl,[I("div",Ml,[I("span",Rl,[(je(),Ke("svg",Il,[I("g",Fl,[I("rect",{x:"9",y:"16",width:"6",height:"16",rx:"3",fill:f.value[0]},null,8,Dl),I("rect",{x:"19",y:"12",width:"6",height:"24",rx:"3",fill:f.value[1]},null,8,Ll),I("rect",{x:"29",y:"8",width:"6",height:"32",rx:"3",fill:f.value[2]},null,8,Hl)])])),C[1]||(C[1]=I("span",{class:"text-2xl leading-none font-extrabold tracking-[-0.03em] italic"},[I("span",{class:"text-strong"},"Driver"),I("span",{class:"text-brandtext"},"Vault")],-1))]),C[5]||(C[5]=I("span",{class:"eyebrow mt-1.5"},"API server",-1)),C[6]||(C[6]=I("div",{class:"flex-1"},null,-1)),I("button",{class:"dh-btn-ghost",title:_t(lt)==="dark"?"Switch to light theme":"Switch to dark theme",onClick:C[0]||(C[0]=(...D)=>_t(Hn)&&_t(Hn)(...D))},[_t(lt)==="dark"?(je(),Ke("svg",$l,[...C[2]||(C[2]=[I("circle",{cx:"12",cy:"12",r:"4"},null,-1),I("path",{"stroke-linecap":"round",d:"M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"},null,-1)])])):(je(),Ke("svg",Nl,[...C[3]||(C[3]=[I("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"},null,-1)])])),C[4]||(C[4]=kt(" Theme ",-1))],8,jl)]),I("div",Ul,[I("div",Wl,[C[8]||(C[8]=I("div",{class:"text-base font-bold tracking-[-0.02em] text-strong"},"Status",-1)),I("span",{class:Ft(["data inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-xs font-medium",l[t.value].cls])},[C[7]||(C[7]=I("span",{class:"h-1.5 w-1.5 rounded-full bg-current"},null,-1)),kt(" "+Ae(l[t.value].label),1),t.value==="error"?(je(),Ke(ae,{key:0},[kt(Ae(s.value),1)],64)):Do("",!0)],2)]),I("div",Bl,[C[9]||(C[9]=I("span",null,"GET /api/health",-1)),I("span",null,Ae(n.value!==null?n.value+"ms":"—"),1),I("span",null,Ae(r.value?"checked "+r.value.toLocaleTimeString():"—"),1)])]),Q(ze,{title:"Public",auth:"No auth",endpoints:d}),Q(ze,{title:"Cars",auth:"Bearer JWT",endpoints:a}),Q(ze,{title:"Service records",auth:"Bearer JWT",endpoints:p}),Q(ze,{title:"Parts",auth:"Bearer JWT",endpoints:T}),Q(ze,{title:"Account",auth:"Bearer JWT",endpoints:S}),Q(ze,{title:"Sessions",auth:"Bearer JWT",endpoints:H}),Q(ze,{title:"Admin",auth:"Admin JWT",endpoints:M}),C[10]||(C[10]=I("p",{class:"eyebrow text-center"}," DriverVault — car maintenance & service tracker. ",-1))]))}};_l(Kl).mount("#app"); diff --git a/API Server/internal/api/dist/index.html b/API Server/internal/api/dist/index.html index 7e5d920..a147693 100644 --- a/API Server/internal/api/dist/index.html +++ b/API Server/internal/api/dist/index.html @@ -6,8 +6,8 @@ DriverVault · API Server - - + +
diff --git a/API Server/internal/api/health.go b/API Server/internal/api/health.go new file mode 100644 index 0000000..f6db46f --- /dev/null +++ b/API Server/internal/api/health.go @@ -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), + }) +} diff --git a/API Server/internal/api/me.go b/API Server/internal/api/me.go index 8327775..8a0dbcd 100644 --- a/API Server/internal/api/me.go +++ b/API Server/internal/api/me.go @@ -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 } diff --git a/API Server/internal/api/orgs.go b/API Server/internal/api/orgs.go new file mode 100644 index 0000000..caa3d9d --- /dev/null +++ b/API Server/internal/api/orgs.go @@ -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 +} diff --git a/API Server/internal/api/parts.go b/API Server/internal/api/parts.go index 40e84e4..98db132 100644 --- a/API Server/internal/api/parts.go +++ b/API Server/internal/api/parts.go @@ -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) { diff --git a/API Server/internal/api/plugins.go b/API Server/internal/api/plugins.go new file mode 100644 index 0000000..800b5e6 --- /dev/null +++ b/API Server/internal/api/plugins.go @@ -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}) +} diff --git a/API Server/internal/api/records.go b/API Server/internal/api/records.go index a1b06c6..67f3519 100644 --- a/API Server/internal/api/records.go +++ b/API Server/internal/api/records.go @@ -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 diff --git a/API Server/internal/api/respond.go b/API Server/internal/api/respond.go new file mode 100644 index 0000000..6e98138 --- /dev/null +++ b/API Server/internal/api/respond.go @@ -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) +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index ddc6657..bae0f22 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -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) } diff --git a/API Server/internal/api/services.go b/API Server/internal/api/services.go index 7abd6b3..471a797 100644 --- a/API Server/internal/api/services.go +++ b/API Server/internal/api/services.go @@ -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) { diff --git a/API Server/internal/api/sessions.go b/API Server/internal/api/sessions.go deleted file mode 100644 index 0f926a3..0000000 --- a/API Server/internal/api/sessions.go +++ /dev/null @@ -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)}) -} diff --git a/API Server/internal/api/settings.go b/API Server/internal/api/settings.go new file mode 100644 index 0000000..2d218e0 --- /dev/null +++ b/API Server/internal/api/settings.go @@ -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), + }) +} diff --git a/API Server/internal/api/shares.go b/API Server/internal/api/shares.go index 547ed13..63185ad 100644 --- a/API Server/internal/api/shares.go +++ b/API Server/internal/api/shares.go @@ -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"}, }) diff --git a/API Server/internal/api/status.go b/API Server/internal/api/status.go new file mode 100644 index 0000000..01b5d49 --- /dev/null +++ b/API Server/internal/api/status.go @@ -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, + }) +} diff --git a/API Server/internal/api/users.go b/API Server/internal/api/users.go new file mode 100644 index 0000000..04fa289 --- /dev/null +++ b/API Server/internal/api/users.go @@ -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 + } +} diff --git a/API Server/internal/auth/jwt.go b/API Server/internal/auth/jwt.go deleted file mode 100644 index 3f2fae9..0000000 --- a/API Server/internal/auth/jwt.go +++ /dev/null @@ -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) -} diff --git a/API Server/internal/config/config.go b/API Server/internal/config/config.go index c853787..d9f7aae 100644 --- a/API Server/internal/config/config.go +++ b/API Server/internal/config/config.go @@ -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) } } } diff --git a/API Server/internal/pb/client.go b/API Server/internal/pb/client.go index 28124d0..3ed0544 100644 --- a/API Server/internal/pb/client.go +++ b/API Server/internal/pb/client.go @@ -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 } diff --git a/API Server/internal/plugins/README.md b/API Server/internal/plugins/README.md new file mode 100644 index 0000000..905d313 --- /dev/null +++ b/API Server/internal/plugins/README.md @@ -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//`. +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: ` 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. diff --git a/API Server/internal/plugins/builtin/builtin.go b/API Server/internal/plugins/builtin/builtin.go new file mode 100644 index 0000000..fcf6605 --- /dev/null +++ b/API Server/internal/plugins/builtin/builtin.go @@ -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// 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 diff --git a/API Server/internal/plugins/doc.go b/API Server/internal/plugins/doc.go new file mode 100644 index 0000000..8423a0c --- /dev/null +++ b/API Server/internal/plugins/doc.go @@ -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. diff --git a/API Server/internal/plugins/external.go b/API Server/internal/plugins/external.go new file mode 100644 index 0000000..961054e --- /dev/null +++ b/API Server/internal/plugins/external.go @@ -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 } diff --git a/API Server/internal/plugins/manager.go b/API Server/internal/plugins/manager.go new file mode 100644 index 0000000..4d96b55 --- /dev/null +++ b/API Server/internal/plugins/manager.go @@ -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) } diff --git a/API Server/internal/plugins/plugin.go b/API Server/internal/plugins/plugin.go new file mode 100644 index 0000000..136d011 --- /dev/null +++ b/API Server/internal/plugins/plugin.go @@ -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 +} diff --git a/API Server/main.go b/API Server/main.go deleted file mode 100644 index 1b4915f..0000000 --- a/API Server/main.go +++ /dev/null @@ -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 -} diff --git a/API Server/panel/src/App.vue b/API Server/panel/src/App.vue index 8f5eabd..49117cd 100644 --- a/API Server/panel/src/App.vue +++ b/API Server/panel/src/App.vue @@ -1,42 +1,42 @@ diff --git a/API Server/panel/src/api.js b/API Server/panel/src/api.js new file mode 100644 index 0000000..c0e8772 --- /dev/null +++ b/API Server/panel/src/api.js @@ -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; + } +} diff --git a/API Server/panel/src/components/LoginView.vue b/API Server/panel/src/components/LoginView.vue new file mode 100644 index 0000000..79a8bcf --- /dev/null +++ b/API Server/panel/src/components/LoginView.vue @@ -0,0 +1,83 @@ + + + diff --git a/API Server/panel/src/components/OrgsCard.vue b/API Server/panel/src/components/OrgsCard.vue new file mode 100644 index 0000000..d3f91bf --- /dev/null +++ b/API Server/panel/src/components/OrgsCard.vue @@ -0,0 +1,127 @@ + + + diff --git a/API Server/panel/src/components/PluginsCard.vue b/API Server/panel/src/components/PluginsCard.vue new file mode 100644 index 0000000..7b26c99 --- /dev/null +++ b/API Server/panel/src/components/PluginsCard.vue @@ -0,0 +1,242 @@ + + + diff --git a/API Server/panel/src/components/PocketBaseCard.vue b/API Server/panel/src/components/PocketBaseCard.vue new file mode 100644 index 0000000..0f4d855 --- /dev/null +++ b/API Server/panel/src/components/PocketBaseCard.vue @@ -0,0 +1,126 @@ + + + diff --git a/API Server/panel/src/components/StatusCard.vue b/API Server/panel/src/components/StatusCard.vue new file mode 100644 index 0000000..8442bec --- /dev/null +++ b/API Server/panel/src/components/StatusCard.vue @@ -0,0 +1,65 @@ + + + diff --git a/API Server/panel/src/components/UsersCard.vue b/API Server/panel/src/components/UsersCard.vue new file mode 100644 index 0000000..73f8401 --- /dev/null +++ b/API Server/panel/src/components/UsersCard.vue @@ -0,0 +1,198 @@ + + + diff --git a/API Server/panel/src/style.css b/API Server/panel/src/style.css index 7373cd9..95c6e55 100644 --- a/API Server/panel/src/style.css +++ b/API Server/panel/src/style.css @@ -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); diff --git a/API Server/scripts/set-role.mjs b/API Server/scripts/set-role.mjs index 37b5f00..3ebad92 100644 --- a/API Server/scripts/set-role.mjs +++ b/API Server/scripts/set-role.mjs @@ -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 +// $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 -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 "); +if (!email || !role || !ROLES.includes(role)) { + console.error(`Usage: node scripts/set-role.mjs <${ROLES.join("|")}>`); process.exit(1); } diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs index 85ad12e..2313a75 100644 --- a/API Server/scripts/setup-pocketbase.mjs +++ b/API Server/scripts/setup-pocketbase.mjs @@ -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) => { diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart index 551e072..e3df0a6 100644 --- a/Phone App/lib/api.dart +++ b/Phone App/lib/api.dart @@ -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.from(data["user"]))); + return (data["token"] as String, AuthUser.fromJson(Map.from(data["record"]))); } // --- cars --- @@ -125,31 +148,35 @@ class ApiClient { Future 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> listUsers() async { - final data = await _send("GET", "/admin/users") as List; - return data.map((e) => AdminUser.fromJson(Map.from(e))).toList(); + final data = await _send("GET", "/users"); + final items = (data["users"] ?? []) as List; + return items.map((e) => AdminUser.fromJson(Map.from(e))).toList(); } Future 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.from(data)); + return AdminUser.fromJson(Map.from(data["user"])); } Future updateUser(String id, {String? name, String? role}) async { final body = {}; 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.from(data)); + final data = await _send("PATCH", "/users/$id", body: body); + return AdminUser.fromJson(Map.from(data["user"])); } + /// Password resets are a field on the user PATCH now, not a separate endpoint. Future setUserPassword(String id, String newPassword) => - _send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword}); + _send("PATCH", "/users/$id", body: {"password": newPassword}); - Future deleteUser(String id) => _send("DELETE", "/admin/users/$id"); + Future deleteUser(String id) => _send("DELETE", "/users/$id"); // --- service records --- Future> listCarServices(String carId) async { @@ -233,15 +260,6 @@ class ApiClient { Future deleteAvatar() => _send("DELETE", "/me/avatar"); - // --- settings: sessions --- - Future> listSessions() async { - final data = await _send("GET", "/sessions") as List; - return data.map((e) => Session.fromJson(Map.from(e))).toList(); - } - - Future revokeSession(String id) => _send("DELETE", "/sessions/$id"); - Future revokeOtherSessions() => _send("DELETE", "/sessions"); - // --- settings: account deletion --- Future requestAccountDeletion(String confirmEmail) async { final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail}); diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 74bd197..5738f41 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -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 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 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 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 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. diff --git a/Phone App/lib/screens/admin_users_screen.dart b/Phone App/lib/screens/admin_users_screen.dart index 85bb506..6c6632b 100644 --- a/Phone App/lib/screens/admin_users_screen.dart +++ b/Phone App/lib/screens/admin_users_screen.dart @@ -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 { 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 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 { 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 { 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 { ], ), 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( - 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( @@ -165,11 +180,15 @@ class _AdminUsersScreenState extends State { 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 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( 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)), ], ), ], diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index f8f6c86..5a4cbae 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -20,7 +20,6 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { UserProfile? _profile; - List _sessions = []; Uint8List? _avatar; bool _loading = true; String? _loadError; @@ -49,7 +48,6 @@ class _SettingsScreenState extends State { 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 { _bio.text = p.bio; setState(() { _profile = p; - _sessions = sessions; _avatar = avatar; }); } catch (e) { @@ -102,7 +99,7 @@ class _SettingsScreenState extends State { 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 sessions; - final Future 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 _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 _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)), - ), - ], - ), - )), + ), ], ); } diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index 7dc161b..2d66cbe 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -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) }), diff --git a/Web App/web/src/auth.js b/Web App/web/src/auth.js index c895f89..6fdf579 100644 --- a/Web App/web/src/auth.js +++ b/Web App/web/src/auth.js @@ -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; } diff --git a/Web App/web/src/views/AdminUsers.vue b/Web App/web/src/views/AdminUsers.vue index add7baa..9f78809 100644 --- a/Web App/web/src/views/AdminUsers.vue +++ b/Web App/web/src/views/AdminUsers.vue @@ -1,5 +1,5 @@