commit ba3f2273618ee99b98bd84292089fbd68e651542 Author: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon Jul 6 08:50:52 2026 +0200 Initial commit diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..6a181be --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,32 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web", + "runtimeExecutable": "C:\\Users\\jania\\carctl-web.cmd", + "runtimeArgs": [], + "port": 5173, + "autoPort": false + }, + { + "name": "panel", + "runtimeExecutable": "C:\\Users\\jania\\carctl-panel.cmd", + "runtimeArgs": [], + "port": 5174, + "autoPort": false + }, + { + "name": "phone", + "runtimeExecutable": "C:\\Python313\\python.exe", + "runtimeArgs": [ + "-m", + "http.server", + "8091", + "--directory", + "E:\\VS Code Projects\\Car Control Project\\Phone App\\build\\web" + ], + "port": 8091, + "autoPort": false + } + ] +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..745a1e2 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,35 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -s -m 5 \"http://10.2.1.10:8027/api/health\")", + "Bash(curl -s -m 5 \"http://10.2.1.10:8027/api/collections\" -o /dev/null -w \"HTTP %{http_code}\\\\n\")", + "PowerShell($g = \\(Get-Command go -ErrorAction SilentlyContinue\\); if \\($g\\) { go version } else { \"go not found in PATH\" })", + "PowerShell(winget install --id GoLang.Go -e --accept-source-agreements --accept-package-agreements --silent)", + "PowerShell($choco = Get-Command choco -ErrorAction SilentlyContinue; $scoop = Get-Command scoop -ErrorAction SilentlyContinue; \"choco: $\\([bool]$choco\\)\"; \"scoop: $\\([bool]$scoop\\)\"; \"Arch: $env:PROCESSOR_ARCHITECTURE\")", + "PowerShell(choco install golang -y --no-progress 2>&1)", + "mcp__mcp-registry__list_connectors", + "Bash(ipconfig)", + "Bash(wmic process *)", + "Bash(find . -iname \"*.env*\" -not -path \"*/node_modules/*\")", + "Bash(netstat -ano)", + "Bash(taskkill //PID 38688 //F)", + "Bash(nohup ./bin/api-server.exe)", + "Bash(disown)", + "Bash(cat /tmp/api-server.log)", + "Bash(find android *)", + "Bash(grep -n \"targetSdk\\\\|compileSdk\\\\|minSdk\" android/app/build.gradle*)", + "PowerShell(Stop-Process -Id 10656 -Force -ErrorAction SilentlyContinue)", + "PowerShell(netstat -ano)", + "PowerShell(Get-Process -Id 32956 -ErrorAction SilentlyContinue)", + "PowerShell(Stop-Process -Force)", + "PowerShell(Start-Process -FilePath \"E:\\\\VS Code Projects\\\\Car Control Project\\\\API Server\\\\bin\\\\api-server.exe\" -WorkingDirectory \"E:\\\\VS Code Projects\\\\Car Control Project\\\\API Server\" -WindowStyle Hidden -RedirectStandardOutput \"E:\\\\VS Code Projects\\\\Car Control Project\\\\API Server\\\\api-server.out.log\" -RedirectStandardError \"E:\\\\VS Code Projects\\\\Car Control Project\\\\API Server\\\\api-server.err.log\")", + "Bash(export PB_URL=\"http://10.2.1.10:8027\")", + "Bash(export PB_ADMIN_EMAIL=\"admin@carcontrole.local\")", + "Bash(export PB_ADMIN_PASSWORD=\"carcontroleadmin2026!\")", + "Bash(node scripts/setup-pocketbase.mjs)", + "PowerShell(Get-Process -Id 44216)", + "Bash(cat \"C:\\\\Users\\\\jania\\\\carctl-web.cmd\" 2>&1 | head -50; echo ---; cat \"E:/VS Code Projects/Car Control Project/Web App/vite.config.js\" 2>&1 || cat \"E:/VS Code Projects/Car Control Project/Web App/vite.config.ts\" 2>&1)", + "Bash(PB_URL=\"http://10.2.1.10:8027\" PB_ADMIN_EMAIL=\"admin@carcontrole.local\" PB_ADMIN_PASSWORD=\"carcontroleadmin2026!\" node scripts/setup-pocketbase.mjs)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0aefa2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Design assets / source (not tracked) +Design/ + +# Dependencies (covers API Server/panel/node_modules, etc.) +node_modules/ + +# OS / editor cruft +.DS_Store +Thumbs.db +*.log +.vscode/* +!.vscode/extensions.json +.idea/ + +# Secrets +.env +*.env.local diff --git a/API Server/.dockerignore b/API Server/.dockerignore new file mode 100644 index 0000000..11fe758 --- /dev/null +++ b/API Server/.dockerignore @@ -0,0 +1,16 @@ +# Keep the build context small and avoid leaking local artifacts/secrets. +.env +*.log +bin/ +tmp/ + +# Panel source & tooling — the built output in internal/api/dist is committed +# and embedded at compile time, so the source tree is not needed in the image. +panel/node_modules/ +panel/dist/ + +# VCS / editor noise +.git/ +.gitignore +.vscode/ +.idea/ diff --git a/API Server/.env.example b/API Server/.env.example new file mode 100644 index 0000000..29bc2d2 --- /dev/null +++ b/API Server/.env.example @@ -0,0 +1,22 @@ +# Copy to .env and fill in. The server reads these at startup. + +# Address the API Server listens on. +PORT=8080 + +# PocketBase instance (database). All DB access goes through this server. +PB_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= + +# Comma-separated list of allowed CORS origins for the web app (dev default). +CORS_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= + +# PocketBase auth collection holding app users (default: users). +AUTH_USERS_COLLECTION=users diff --git a/API Server/.gitignore b/API Server/.gitignore new file mode 100644 index 0000000..4c7c53b --- /dev/null +++ b/API Server/.gitignore @@ -0,0 +1,4 @@ +.env +*.exe +/tmp/ +/bin/ diff --git a/API Server/Dockerfile b/API Server/Dockerfile new file mode 100644 index 0000000..e300eab --- /dev/null +++ b/API Server/Dockerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1 + +# --- Build stage ------------------------------------------------------------- +# Compile a static Go binary. The Vue panel is pre-built into internal/api/dist +# and embedded via //go:embed, so no Node toolchain is needed here. +FROM golang:1.22-alpine AS build + +WORKDIR /src + +# Cache module downloads separately from the source for faster rebuilds. +COPY go.mod ./ +# go.sum is optional (stdlib-only module today); copy it if present. +COPY go.su[m] ./ +RUN go mod download + +COPY . . + +# CGO_ENABLED=0 produces a static binary that runs on a bare alpine image. +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server . + +# --- Runtime stage ----------------------------------------------------------- +FROM alpine:latest + +# HTTPS calls to PocketBase need CA certificates; tzdata for correct timestamps. +RUN apk add --no-cache ca-certificates tzdata + +# Run as an unprivileged user. +RUN addgroup -S app && adduser -S -G app app + +WORKDIR /app +COPY --from=build /out/api-server /app/api-server + +# Config comes entirely from environment variables (see .env.example). +# PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required at startup. +ENV PORT=8080 +EXPOSE 8080 + +USER app +ENTRYPOINT ["/app/api-server"] diff --git a/API Server/README.md b/API Server/README.md new file mode 100644 index 0000000..d170570 --- /dev/null +++ b/API Server/README.md @@ -0,0 +1,190 @@ +# Car Control — 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. + +## Data model (from `Car Service.xlsx`) + +| 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` | +| `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 | +| `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 | + +**Spreadsheet formulas**, reproduced by the API on read: + +``` +Next Service Date = service date + serviceIntervalDays (Excel: =A+365) +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 + +``` +GET /api/health + +POST /api/auth/login +GET /api/auth/me + +# current user (profile / appearance / avatar / data / account lifecycle) +GET /api/me PATCH /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 DELETE /api/me + +# active sessions +GET /api/sessions +DELETE /api/sessions/{id} DELETE /api/sessions (revoke all others) + +# 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} + +# cars + sharing +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 +DELETE /api/cars/{id}/shares/{userId} + +# service records + parts +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/cars` returns the caller's owned cars plus any shared with them, each +annotated with an `access` field. `GET /api/service-records?car={id}` and +`GET /api/parts?car={id}` filter by car. + +> **Gotcha:** `updateCar` rewrites **all** car columns from the payload, so a +> `PATCH /api/cars/{id}` must send the **full** car object — omitted spec fields +> get blanked. (The phone's odometer quick-edit sends the whole car for this +> reason.) + +## Layout + +``` +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 +``` + +## Configuration + +Copy `.env.example` to `.env` and fill in: + +``` +PORT=8080 +PB_URL=http://10.2.1.10:8027 +PB_ADMIN_EMAIL=... +PB_ADMIN_PASSWORD=... +AUTH_SECRET= +CORS_ORIGINS=http://localhost:5173 +``` + +`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. + +## 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 +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`). + +> **PocketBase note:** collections created by the setup script do **not** get +> automatic `created`/`updated` autodate fields in this PocketBase version — +> sorting on `created` fails unless an explicit `F.autodate(...)` is added. When +> adding a new car spec field, extend `DESIRED.cars` in `setup-pocketbase.mjs`, +> add it to `models.Car` + the record mapping in `records.go`, then rebuild. + +## First-time setup + +1. **Create the PocketBase collections** (idempotent): + + ```powershell + $env:PB_URL="http://10.2.1.10:8027" + $env:PB_ADMIN_EMAIL="you@example.com" + $env:PB_ADMIN_PASSWORD="secret" + node scripts/setup-pocketbase.mjs + ``` + +2. **Run the server** — `go run .`, or build and run the binary (below). + +3. **(Optional) Seed from the spreadsheet** with the server running (see scripts). + +## Build & run + +```powershell +go build -o bin/api-server.exe . +``` + +The deployment runs the **prebuilt binary** `bin/api-server.exe` (not `go run`), +started detached so it survives the shell: + +```powershell +Start-Process -FilePath ".\bin\api-server.exe" -WorkingDirectory "." ` + -WindowStyle Hidden -RedirectStandardOutput api-server.out.log ` + -RedirectStandardError api-server.err.log +``` + +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. diff --git a/API Server/docker-compose.yml b/API Server/docker-compose.yml new file mode 100644 index 0000000..8c4a0d0 --- /dev/null +++ b/API Server/docker-compose.yml @@ -0,0 +1,20 @@ +services: + api-server: + build: + context: . + dockerfile: Dockerfile + image: carcontrol-api + container_name: carcontrol-api + restart: unless-stopped + ports: + - "${PORT:-8080}:8080" + environment: + # Container always listens on 8080; map the host port above. + PORT: "8080" + # External PocketBase instance (all DB access goes through this server). + PB_URL: "${PB_URL:-http://10.2.1.10:8027}" + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" + CORS_ORIGINS: "${CORS_ORIGINS:-http://localhost:5173}" + AUTH_SECRET: "${AUTH_SECRET}" + AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" diff --git a/API Server/go.mod b/API Server/go.mod new file mode 100644 index 0000000..f94ed0e --- /dev/null +++ b/API Server/go.mod @@ -0,0 +1,3 @@ +module carcontrol/api + +go 1.22 diff --git a/API Server/internal/api/admin.go b/API Server/internal/api/admin.go new file mode 100644 index 0000000..d4ccf94 --- /dev/null +++ b/API Server/internal/api/admin.go @@ -0,0 +1,260 @@ +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 new file mode 100644 index 0000000..5d4733c --- /dev/null +++ b/API Server/internal/api/auth.go @@ -0,0 +1,272 @@ +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 + +// publicPaths bypass authentication. Everything else requires a valid token. +var publicPaths = map[string]bool{ + "/api/health": true, + "/api/auth/login": true, +} + +type ctxKey string + +const claimsKey ctxKey = "claims" + +// withAuth rejects requests to non-public paths that lack a valid bearer token. +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 + // panel and its static assets), bypass authentication. + if publicPaths[r.URL.Path] || !strings.HasPrefix(r.URL.Path, "/api/") { + next.ServeHTTP(w, r) + return + } + token := bearerToken(r) + if token == "" { + writeError(w, http.StatusUnauthorized, "missing bearer token") + 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)) + }) +} + +// 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 + } + res, err := s.pb.List(ctx, colSessions, url.Values{ + "filter": {fmt.Sprintf("jti='%s'", jti)}, + "perPage": {"1"}, + }) + if err != nil { + return true + } + var recs []sessionRecord + if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 { + return true + } + return recs[0].Revoked +} + +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if h == "" { + return "" + } + // 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"` +} + +type userInfo struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + 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}) +} diff --git a/API Server/internal/api/cars.go b/API Server/internal/api/cars.go new file mode 100644 index 0000000..18d2c45 --- /dev/null +++ b/API Server/internal/api/cars.go @@ -0,0 +1,235 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "carcontrol/api/internal/models" +) + +// Access levels a user can have on a car. accessNone means no access at all. +const ( + accessNone = "" + accessRead = "read" + accessWrite = "write" + accessOwner = "owner" +) + +// carAccessLevel reports the requesting user's permission on a car: "owner" if +// they own it, otherwise the permission from any car_shares grant ("read"/ +// "write"), otherwise "" (no access). It also returns the car record so callers +// that already need it avoid a second fetch. +func (s *Server) carAccessLevel(ctx context.Context, userID, carID string) (string, *carRecord, error) { + var rec carRecord + if err := s.pb.GetOne(ctx, colCars, carID, &rec); err != nil { + return accessNone, nil, err + } + if rec.Owner == userID { + return accessOwner, &rec, nil + } + perm, err := s.sharePermission(ctx, carID, userID) + if err != nil { + return accessNone, &rec, err + } + return perm, &rec, nil +} + +// sharePermission returns the permission ("read"/"write") granted to userID on +// carID via car_shares, or "" if there is no grant. +func (s *Server) sharePermission(ctx context.Context, carID, userID string) (string, error) { + res, err := s.pb.List(ctx, colShares, url.Values{ + "filter": {fmt.Sprintf("car='%s' && user='%s'", carID, userID)}, + "perPage": {"1"}, + }) + if err != nil { + return "", err + } + var recs []shareRecord + if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 { + return "", err + } + return recs[0].Permission, nil +} + +func canWrite(level string) bool { return level == accessOwner || level == accessWrite } + +// requireCarAccess enforces that the current user's access to carID meets the +// minimum `need` (accessRead = any access, accessWrite = write or owner, +// accessOwner = owner only). On failure it writes the HTTP response and returns +// false, so callers can `if !s.requireCarAccess(...) { return }`. +func (s *Server) requireCarAccess(w http.ResponseWriter, r *http.Request, carID, need string) bool { + if carID == "" { + writeError(w, http.StatusBadRequest, "car is required") + return false + } + level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), carID) + if err != nil { + writePBError(w, err) + return false + } + var ok bool + switch need { + case accessWrite: + ok = canWrite(level) + case accessOwner: + ok = level == accessOwner + default: // accessRead / any + ok = level != accessNone + } + if !ok { + writeError(w, http.StatusForbidden, "you do not have access to this car") + return false + } + return true +} + +func (s *Server) listCars(w http.ResponseWriter, r *http.Request) { + me := s.currentUserID(r) + + // Cars the user owns. + ownedRes, err := s.pb.List(r.Context(), colCars, url.Values{ + "filter": {fmt.Sprintf("owner='%s'", me)}, + "sort": {"name"}, + "perPage": {"200"}, + }) + if err != nil { + writePBError(w, err) + return + } + var owned []carRecord + if err := json.Unmarshal(ownedRes.Items, &owned); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + out := make([]models.Car, 0, len(owned)) + for _, rec := range owned { + m := rec.toModel() + m.Access = accessOwner + out = append(out, m) + } + + // Cars shared with the user (each grant → fetch the car, annotate access). + sharesRes, err := s.pb.List(r.Context(), colShares, url.Values{ + "filter": {fmt.Sprintf("user='%s'", me)}, + "perPage": {"200"}, + }) + if err != nil { + writePBError(w, err) + return + } + var shares []shareRecord + if err := json.Unmarshal(sharesRes.Items, &shares); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + for _, sh := range shares { + var rec carRecord + if err := s.pb.GetOne(r.Context(), colCars, sh.Car, &rec); err != nil { + continue // grant points at a deleted car; skip defensively + } + m := rec.toModel() + m.Access = sh.Permission + out = append(out, m) + } + + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) getCar(w http.ResponseWriter, r *http.Request) { + level, rec, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id")) + if err != nil { + writePBError(w, err) + return + } + if level == accessNone { + writeError(w, http.StatusForbidden, "you do not have access to this car") + return + } + m := rec.toModel() + m.Access = level + writeJSON(w, http.StatusOK, m) +} + +func (s *Server) createCar(w http.ResponseWriter, r *http.Request) { + var in models.Car + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if in.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + applyCarDefaults(&in) + + // Owner is always the authenticated user; ignore any client-supplied owner. + payload := carPayload(in) + payload["owner"] = s.currentUserID(r) + + var rec carRecord + if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil { + writePBError(w, err) + return + } + m := rec.toModel() + m.Access = accessOwner + writeJSON(w, http.StatusCreated, m) +} + +func (s *Server) updateCar(w http.ResponseWriter, r *http.Request) { + var in models.Car + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id")) + if err != nil { + writePBError(w, err) + return + } + if !canWrite(level) { + writeError(w, http.StatusForbidden, "you cannot edit this car") + return + } + // carPayload deliberately omits owner, so a PATCH never reassigns ownership. + var rec carRecord + if err := s.pb.Update(r.Context(), colCars, r.PathValue("id"), carPayload(in), &rec); err != nil { + writePBError(w, err) + return + } + m := rec.toModel() + m.Access = level + writeJSON(w, http.StatusOK, m) +} + +func (s *Server) deleteCar(w http.ResponseWriter, r *http.Request) { + level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id")) + if err != nil { + writePBError(w, err) + return + } + if level != accessOwner { + writeError(w, http.StatusForbidden, "only the owner can delete this car") + return + } + if err := s.pb.Delete(r.Context(), colCars, r.PathValue("id")); err != nil { + writePBError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// applyCarDefaults fills the spreadsheet's default maintenance intervals when +// the client didn't specify them. +func applyCarDefaults(c *models.Car) { + if c.ServiceIntervalDays <= 0 { + c.ServiceIntervalDays = 365 + } + if c.ServiceIntervalKm <= 0 { + c.ServiceIntervalKm = 15000 + } +} diff --git a/API Server/internal/api/dist/assets/index-DfVJ8vbT.css b/API Server/internal/api/dist/assets/index-DfVJ8vbT.css new file mode 100644 index 0000000..e2c98e1 --- /dev/null +++ b/API Server/internal/api/dist/assets/index-DfVJ8vbT.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-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-o_I931vi.js b/API Server/internal/api/dist/assets/index-o_I931vi.js new file mode 100644 index 0000000..55ae333 --- /dev/null +++ b/API Server/internal/api/dist/assets/index-o_I931vi.js @@ -0,0 +1,17 @@ +(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/favicon.svg b/API Server/internal/api/dist/favicon.svg new file mode 100644 index 0000000..079bcb7 --- /dev/null +++ b/API Server/internal/api/dist/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/API Server/internal/api/dist/index.html b/API Server/internal/api/dist/index.html new file mode 100644 index 0000000..7e5d920 --- /dev/null +++ b/API Server/internal/api/dist/index.html @@ -0,0 +1,15 @@ + + + + + + + + DriverVault · API Server + + + + +
+ + diff --git a/API Server/internal/api/me.go b/API Server/internal/api/me.go new file mode 100644 index 0000000..8327775 --- /dev/null +++ b/API Server/internal/api/me.go @@ -0,0 +1,534 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "carcontrol/api/internal/auth" + "carcontrol/api/internal/models" +) + +// deletionCooldown is how long an account-deletion request sits before it can +// be finalized, giving the user a window to change their mind. +const deletionCooldown = 3 * 24 * time.Hour + +// userRecord is the PocketBase-facing shape of a user (mixes PocketBase's own +// camelCase system fields with our snake_case custom ones). +type userRecord struct { + ID string `json:"id"` + Email string `json:"email"` + Verified bool `json:"verified"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Bio string `json:"bio"` + Theme string `json:"theme"` + Locale string `json:"locale"` + DateFormat string `json:"date_format"` + FontSize string `json:"font_size"` + DeletionRequestedAt string `json:"deletion_requested_at"` + Role string `json:"role"` + Created string `json:"created"` +} + +func (rec userRecord) toModel() models.User { + u := models.User{ + ID: rec.ID, + Email: rec.Email, + Verified: rec.Verified, + Name: rec.Name, + Bio: rec.Bio, + HasAvatar: rec.Avatar != "", + Theme: orDefault(rec.Theme, "system"), + Locale: orDefault(rec.Locale, "en-US"), + DateFormat: orDefault(rec.DateFormat, "YMD"), + FontSize: orDefault(rec.FontSize, "medium"), + Role: orDefault(rec.Role, "user"), + Created: rec.Created, + } + if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() { + u.DeletionRequestedAt = &t + } + return u +} + +func orDefault(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +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 { + 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 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + rec, err := s.fetchUser(r, claims.Sub) + if err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, rec.toModel()) +} + +type updateMeRequest struct { + Name *string `json:"name"` + Bio *string `json:"bio"` + Theme *string `json:"theme"` + Locale *string `json:"locale"` + DateFormat *string `json:"dateFormat"` + FontSize *string `json:"fontSize"` +} + +var validThemes = map[string]bool{"light": true, "dark": true, "system": true} +var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true, "MDY": true} +var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true} + +// handleUpdateMe applies a partial update — only fields present in the request +// 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 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + var in updateMeRequest + 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.Bio != nil { + payload["bio"] = *in.Bio + } + if in.Theme != nil { + if !validThemes[*in.Theme] { + writeError(w, http.StatusBadRequest, "theme must be light, dark, or system") + return + } + payload["theme"] = *in.Theme + } + if in.Locale != nil { + payload["locale"] = *in.Locale + } + if in.DateFormat != nil { + if !validDateFormats[*in.DateFormat] { + writeError(w, http.StatusBadRequest, "dateFormat must be YMD, DMY, or MDY") + return + } + payload["date_format"] = *in.DateFormat + } + if in.FontSize != nil { + if !validFontSizes[*in.FontSize] { + writeError(w, http.StatusBadRequest, "fontSize must be small, medium, or large") + return + } + payload["font_size"] = *in.FontSize + } + + var rec userRecord + if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, &rec); err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, rec.toModel()) +} + +type changePasswordRequest struct { + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword"` +} + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(claimsKey).(*auth.Claims) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + var in changePasswordRequest + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if in.OldPassword == "" { + writeError(w, http.StatusBadRequest, "current password is required") + return + } + if len(in.NewPassword) < 8 { + writeError(w, http.StatusBadRequest, "new password must be at least 8 characters") + return + } + + // 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 { + 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 { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(claimsKey).(*auth.Claims) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + if err := r.ParseMultipartForm(5 << 20); err != nil { + writeError(w, http.StatusBadRequest, "avatar upload must be under 5MB") + return + } + file, header, err := r.FormFile("avatar") + if err != nil { + writeError(w, http.StatusBadRequest, "missing avatar file") + return + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not read upload") + return + } + + if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection, claims.Sub, nil, "avatar", header.Filename, data); err != nil { + writePBError(w, err) + return + } + rec, err := s.fetchUser(r, claims.Sub) + if err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, rec.toModel()) +} + +func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(claimsKey).(*auth.Claims) + if !ok { + 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 { + writePBError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(claimsKey).(*auth.Claims) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + rec, err := s.fetchUser(r, claims.Sub) + if err != nil { + writePBError(w, err) + return + } + if rec.Avatar == "" { + writeError(w, http.StatusNotFound, "no avatar set") + return + } + data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection, rec.ID, rec.Avatar) + if err != nil { + writePBError(w, err) + return + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "private, max-age=300") + w.Write(data) +} + +func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(claimsKey).(*auth.Claims) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + if err := s.pb.RequestVerification(r.Context(), s.usersCollection, claims.Email); err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "requested"}) +} + +// handleExportData bundles the account profile plus every car the user owns +// (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 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + user, err := s.fetchUser(r, claims.Sub) + if err != nil { + writePBError(w, err) + return + } + + carsRes, err := s.pb.List(r.Context(), colCars, url.Values{ + "filter": {fmt.Sprintf("owner='%s'", claims.Sub)}, + "sort": {"name"}, + "perPage": {"200"}, + }) + if err != nil { + writePBError(w, err) + return + } + var carRecs []carRecord + if err := json.Unmarshal(carsRes.Items, &carRecs); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + type carExport struct { + models.Car + ServiceRecords []models.ServiceRecord `json:"serviceRecords"` + Parts []models.Part `json:"parts"` + } + exportCars := make([]carExport, 0, len(carRecs)) + for _, cr := range carRecs { + car := cr.toModel() + + svcRes, err := s.pb.List(r.Context(), colServices, url.Values{ + "filter": {fmt.Sprintf("car='%s'", car.ID)}, "sort": {"-date"}, "perPage": {"500"}, + }) + if err != nil { + writePBError(w, err) + return + } + var svcRecs []serviceRecord + if err := json.Unmarshal(svcRes.Items, &svcRecs); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + services := make([]models.ServiceRecord, 0, len(svcRecs)) + for _, sr := range svcRecs { + m := sr.toModel() + m.ComputeDerived(&car) + services = append(services, m) + } + + partsRes, err := s.pb.List(r.Context(), colParts, url.Values{ + "filter": {fmt.Sprintf("car='%s'", car.ID)}, "perPage": {"500"}, + }) + if err != nil { + writePBError(w, err) + return + } + var partRecs []partRecord + if err := json.Unmarshal(partsRes.Items, &partRecs); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + parts := make([]models.Part, 0, len(partRecs)) + for _, p := range partRecs { + parts = append(parts, p.toModel()) + } + + exportCars = append(exportCars, carExport{Car: car, ServiceRecords: services, Parts: parts}) + } + + out := map[string]any{ + "exportedAt": time.Now().UTC().Format(time.RFC3339), + "account": user.toModel(), + "cars": exportCars, + } + + filename := fmt.Sprintf("car-control-export-%s.json", time.Now().UTC().Format("2006-01-02")) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) + _ = json.NewEncoder(w).Encode(out) +} + +// importCar mirrors handleExportData's per-car shape. The "id" and the +// service records'/parts' "car" fields in the uploaded file are ignored — +// this always creates brand-new records, remapped to the newly created car, +// rather than trying to match or overwrite anything that already exists. +type importCar struct { + models.Car + ServiceRecords []models.ServiceRecord `json:"serviceRecords"` + Parts []models.Part `json:"parts"` +} + +type importRequest struct { + Cars []importCar `json:"cars"` +} + +type importResult struct { + CarsImported int `json:"carsImported"` + ServicesImported int `json:"servicesImported"` + PartsImported int `json:"partsImported"` +} + +// handleImportData adds cars/service-records/parts from a previously exported +// JSON file (or a hand-built one in the same shape). It only ever creates new +// records — it does not merge with or overwrite existing shared household +// data. Deliberately lenient about unknown top-level fields (e.g. the +// 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 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + var in importRequest + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid import file: "+err.Error()) + return + } + if len(in.Cars) == 0 { + writeError(w, http.StatusBadRequest, "import file has no cars") + return + } + + var result importResult + for _, ic := range in.Cars { + car := ic.Car + if car.Name == "" { + continue // skip malformed entries rather than failing the whole import + } + applyCarDefaults(&car) + + // Imported cars are owned by the importing user, regardless of any + // owner in the file. + payload := carPayload(car) + payload["owner"] = claims.Sub + + var rec carRecord + if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil { + writePBError(w, err) + return + } + result.CarsImported++ + + for _, svc := range ic.ServiceRecords { + svc.Car = rec.ID + if err := s.pb.Create(r.Context(), colServices, servicePayload(svc), nil); err != nil { + writePBError(w, err) + return + } + result.ServicesImported++ + } + for _, p := range ic.Parts { + p.Car = rec.ID + if err := s.pb.Create(r.Context(), colParts, partPayload(p), nil); err != nil { + writePBError(w, err) + return + } + result.PartsImported++ + } + } + + writeJSON(w, http.StatusOK, result) +} + +type deleteAccountRequest struct { + ConfirmEmail string `json:"confirmEmail"` +} + +// 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 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + var in deleteAccountRequest + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if !strings.EqualFold(strings.TrimSpace(in.ConfirmEmail), claims.Email) { + writeError(w, http.StatusBadRequest, "typed email does not match your account email") + return + } + + 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 { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "deletionRequestedAt": now.Format(time.RFC3339), + "eligibleAt": now.Add(deletionCooldown).Format(time.RFC3339), + }) +} + +func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) { + claims, ok := r.Context().Value(claimsKey).(*auth.Claims) + if !ok { + 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 { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"}) +} + +// handleFinalizeDeletion actually deletes the account, but only once the +// cooldown started by handleRequestDeletion has elapsed. Deleting the user +// record cascades to their "sessions" rows (cascadeDelete relation); the +// 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 { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + rec, err := s.fetchUser(r, claims.Sub) + if err != nil { + writePBError(w, err) + return + } + requestedAt := parsePBDate(rec.DeletionRequestedAt) + if requestedAt.IsZero() { + writeError(w, http.StatusBadRequest, "no deletion request is pending") + return + } + if time.Now().UTC().Before(requestedAt.Add(deletionCooldown)) { + 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 { + writePBError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/API Server/internal/api/panel.go b/API Server/internal/api/panel.go new file mode 100644 index 0000000..2711b86 --- /dev/null +++ b/API Server/internal/api/panel.go @@ -0,0 +1,23 @@ +package api + +import ( + "embed" + "io/fs" + "net/http" +) + +// The DriverVault API panel: a Vue 3 + Tailwind app (source in panel/, built with +// `npm run build` into internal/api/dist) embedded at compile time and served +// at the server root. It shows a live health status and the REST reference. +// +//go:embed all:dist +var panelFS embed.FS + +// panelHandler serves the built panel assets from the embedded dist directory. +func panelHandler() http.Handler { + sub, err := fs.Sub(panelFS, "dist") + if err != nil { + panic(err) // embedded dist is malformed; unreachable in a valid build + } + return http.FileServerFS(sub) +} diff --git a/API Server/internal/api/parts.go b/API Server/internal/api/parts.go new file mode 100644 index 0000000..40e84e4 --- /dev/null +++ b/API Server/internal/api/parts.go @@ -0,0 +1,124 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "carcontrol/api/internal/models" +) + +func (s *Server) listParts(w http.ResponseWriter, r *http.Request) { + carID := r.URL.Query().Get("car") + if !s.requireCarAccess(w, r, carID, accessRead) { + return + } + q := url.Values{} + q.Set("sort", "name") + q.Set("perPage", "500") + q.Set("filter", fmt.Sprintf("car='%s'", carID)) + s.respondPartList(w, r, q) +} + +// listCarParts serves GET /api/cars/{id}/parts. +func (s *Server) listCarParts(w http.ResponseWriter, r *http.Request) { + carID := r.PathValue("id") + if !s.requireCarAccess(w, r, carID, accessRead) { + return + } + q := url.Values{} + q.Set("sort", "name") + q.Set("perPage", "500") + q.Set("filter", fmt.Sprintf("car='%s'", carID)) + s.respondPartList(w, r, q) +} + +func (s *Server) respondPartList(w http.ResponseWriter, r *http.Request, q url.Values) { + res, err := s.pb.List(r.Context(), colParts, q) + if err != nil { + writePBError(w, err) + return + } + var recs []partRecord + if err := json.Unmarshal(res.Items, &recs); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out := make([]models.Part, 0, len(recs)) + for _, rec := range recs { + out = append(out, rec.toModel()) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) getPart(w http.ResponseWriter, r *http.Request) { + var rec partRecord + if err := s.pb.GetOne(r.Context(), colParts, r.PathValue("id"), &rec); err != nil { + writePBError(w, err) + return + } + if !s.requireCarAccess(w, r, rec.Car, accessRead) { + return + } + writeJSON(w, http.StatusOK, rec.toModel()) +} + +func (s *Server) createPart(w http.ResponseWriter, r *http.Request) { + var in models.Part + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if in.Car == "" || in.Name == "" { + writeError(w, http.StatusBadRequest, "car and name are required") + return + } + if !s.requireCarAccess(w, r, in.Car, accessWrite) { + return + } + var rec partRecord + if err := s.pb.Create(r.Context(), colParts, partPayload(in), &rec); err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusCreated, rec.toModel()) +} + +func (s *Server) updatePart(w http.ResponseWriter, r *http.Request) { + var in models.Part + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + var existing partRecord + if err := s.pb.GetOne(r.Context(), colParts, r.PathValue("id"), &existing); err != nil { + writePBError(w, err) + return + } + if !s.requireCarAccess(w, r, existing.Car, accessWrite) { + return + } + var rec partRecord + if err := s.pb.Update(r.Context(), colParts, r.PathValue("id"), partPayload(in), &rec); err != nil { + writePBError(w, err) + return + } + writeJSON(w, http.StatusOK, rec.toModel()) +} + +func (s *Server) deletePart(w http.ResponseWriter, r *http.Request) { + var existing partRecord + if err := s.pb.GetOne(r.Context(), colParts, r.PathValue("id"), &existing); err != nil { + writePBError(w, err) + return + } + if !s.requireCarAccess(w, r, existing.Car, accessWrite) { + return + } + if err := s.pb.Delete(r.Context(), colParts, r.PathValue("id")); err != nil { + writePBError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/API Server/internal/api/records.go b/API Server/internal/api/records.go new file mode 100644 index 0000000..a1b06c6 --- /dev/null +++ b/API Server/internal/api/records.go @@ -0,0 +1,192 @@ +package api + +import ( + "strings" + "time" + + "carcontrol/api/internal/models" +) + +// PocketBase stores datetimes as e.g. "2015-06-12 00:00:00.000Z". These layouts +// are tried (in order) when parsing values coming back from PocketBase. +var pbDateLayouts = []string{ + "2006-01-02 15:04:05.000Z", + "2006-01-02 15:04:05Z", + time.RFC3339, + "2006-01-02", +} + +func parsePBDate(s string) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + for _, l := range pbDateLayouts { + if t, err := time.Parse(l, s); err == nil { + return t + } + } + return time.Time{} +} + +// formatPBDate renders a date in the format PocketBase expects on write. +func formatPBDate(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format("2006-01-02 15:04:05.000Z") +} + +// --- cars --- + +// carRecord is the PocketBase-facing shape of a car (snake_case fields). +type carRecord struct { + ID string `json:"id"` + Name string `json:"name"` + Make string `json:"make"` + Model string `json:"model"` + Year int `json:"year"` + Registration string `json:"registration"` + RegistrationCountry string `json:"registration_country"` + VIN string `json:"vin"` + ServiceIntervalDays int `json:"service_interval_days"` + ServiceIntervalKm int `json:"service_interval_km"` + OilSpec string `json:"oil_spec"` + TransmissionOilSpec string `json:"transmission_oil_spec"` + DifferentialOilSpec string `json:"differential_oil_spec"` + BrakeFluidSpec string `json:"brake_fluid_spec"` + CoolantSpec string `json:"coolant_spec"` + CurrentKm int `json:"current_km"` + FuelType string `json:"fuel_type"` + BuildDate string `json:"build_date"` + FirstRegistrationDate string `json:"first_registration_date"` + Owner string `json:"owner"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +func (rec carRecord) toModel() models.Car { + return models.Car{ + ID: rec.ID, + Name: rec.Name, + Make: rec.Make, + Model: rec.Model, + Year: rec.Year, + Registration: rec.Registration, + RegistrationCountry: rec.RegistrationCountry, + VIN: rec.VIN, + ServiceIntervalDays: rec.ServiceIntervalDays, + ServiceIntervalKm: rec.ServiceIntervalKm, + OilSpec: rec.OilSpec, + TransmissionOilSpec: rec.TransmissionOilSpec, + DifferentialOilSpec: rec.DifferentialOilSpec, + BrakeFluidSpec: rec.BrakeFluidSpec, + CoolantSpec: rec.CoolantSpec, + CurrentKm: rec.CurrentKm, + FuelType: rec.FuelType, + BuildDate: rec.BuildDate, + FirstRegistrationDate: rec.FirstRegistrationDate, + Owner: rec.Owner, + Created: rec.Created, + Updated: rec.Updated, + } +} + +// carPayload builds the write payload for create/update from a domain Car. +func carPayload(c models.Car) map[string]any { + return map[string]any{ + "name": c.Name, + "make": c.Make, + "model": c.Model, + "year": c.Year, + "registration": c.Registration, + "registration_country": c.RegistrationCountry, + "vin": c.VIN, + "service_interval_days": c.ServiceIntervalDays, + "service_interval_km": c.ServiceIntervalKm, + "oil_spec": c.OilSpec, + "transmission_oil_spec": c.TransmissionOilSpec, + "differential_oil_spec": c.DifferentialOilSpec, + "brake_fluid_spec": c.BrakeFluidSpec, + "coolant_spec": c.CoolantSpec, + "current_km": c.CurrentKm, + "fuel_type": c.FuelType, + "build_date": c.BuildDate, + "first_registration_date": c.FirstRegistrationDate, + } +} + +// --- service records --- + +type serviceRecord struct { + ID string `json:"id"` + Car string `json:"car"` + Date string `json:"date"` + Km int `json:"km"` + ChangedOil bool `json:"changed_oil"` + ChangedEngineAirFilter bool `json:"changed_engine_air_filter"` + ChangedCabinAirFilter bool `json:"changed_cabin_air_filter"` + Notes string `json:"notes"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +func (rec serviceRecord) toModel() models.ServiceRecord { + return models.ServiceRecord{ + ID: rec.ID, + Car: rec.Car, + Date: parsePBDate(rec.Date), + Km: rec.Km, + ChangedOil: rec.ChangedOil, + ChangedEngineAirFilter: rec.ChangedEngineAirFilter, + ChangedCabinAirFilter: rec.ChangedCabinAirFilter, + Notes: rec.Notes, + Created: rec.Created, + Updated: rec.Updated, + } +} + +func servicePayload(r models.ServiceRecord) map[string]any { + return map[string]any{ + "car": r.Car, + "date": formatPBDate(r.Date), + "km": r.Km, + "changed_oil": r.ChangedOil, + "changed_engine_air_filter": r.ChangedEngineAirFilter, + "changed_cabin_air_filter": r.ChangedCabinAirFilter, + "notes": r.Notes, + } +} + +// --- parts --- + +type partRecord struct { + ID string `json:"id"` + Car string `json:"car"` + Name string `json:"name"` + PartNumber string `json:"part_number"` + Category string `json:"category"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +func (rec partRecord) toModel() models.Part { + return models.Part{ + ID: rec.ID, + Car: rec.Car, + Name: rec.Name, + PartNumber: rec.PartNumber, + Category: rec.Category, + Created: rec.Created, + Updated: rec.Updated, + } +} + +func partPayload(p models.Part) map[string]any { + return map[string]any{ + "car": p.Car, + "name": p.Name, + "part_number": p.PartNumber, + "category": p.Category, + } +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go new file mode 100644 index 0000000..ddc6657 --- /dev/null +++ b/API Server/internal/api/server.go @@ -0,0 +1,225 @@ +// Package api exposes the HTTP REST surface of the car-control API Server. +// +// Clients (web app, phone app, ...) talk only to this server; this server is +// the only thing that talks to PocketBase. Endpoints: +// +// GET /api/health +// 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 +// DELETE /api/cars/{id}/shares/{userId} +// 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} +package api + +import ( + "encoding/json" + "log" + "net/http" + "time" + + "carcontrol/api/internal/pb" +) + +// PocketBase collection names. +const ( + colCars = "cars" + colServices = "service_records" + colParts = "parts" + colSessions = "sessions" + colShares = "car_shares" +) + +type Server struct { + pb *pb.Client + corsOrigins map[string]bool + authSecret string + usersCollection string +} + +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 + } + return &Server{ + pb: client, + corsOrigins: set, + authSecret: opts.AuthSecret, + usersCollection: opts.UsersCollection, + } +} + +// Handler builds the routed, CORS-wrapped HTTP handler (Go 1.22 ServeMux). +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. + panel := panelHandler() + mux.Handle("GET /{$}", panel) + mux.Handle("GET /assets/", panel) + mux.Handle("GET /favicon.svg", panel) + + mux.HandleFunc("GET /api/health", s.handleHealth) + + mux.HandleFunc("POST /api/auth/login", s.handleLogin) + mux.HandleFunc("GET /api/auth/me", s.handleMe) + + mux.HandleFunc("GET /api/me", s.handleGetMe) + mux.HandleFunc("PATCH /api/me", s.handleUpdateMe) + mux.HandleFunc("POST /api/me/password", s.handleChangePassword) + mux.HandleFunc("POST /api/me/avatar", s.handleUploadAvatar) + mux.HandleFunc("GET /api/me/avatar", s.handleGetAvatar) + mux.HandleFunc("DELETE /api/me/avatar", s.handleDeleteAvatar) + mux.HandleFunc("POST /api/me/verify/request", s.handleRequestVerification) + mux.HandleFunc("GET /api/me/export", s.handleExportData) + mux.HandleFunc("POST /api/me/import", s.handleImportData) + mux.HandleFunc("POST /api/me/delete", s.handleRequestDeletion) + 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) + + 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) + + mux.HandleFunc("GET /api/cars", s.listCars) + mux.HandleFunc("POST /api/cars", s.createCar) + mux.HandleFunc("GET /api/cars/{id}", s.getCar) + mux.HandleFunc("PATCH /api/cars/{id}", s.updateCar) + 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) + + 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) + + 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))) +} + +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), + }) +} + +// --- middleware --- + +func (s *Server) withCORS(next http.Handler) http.Handler { + 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") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + } + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +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)) + }) +} + +// --- 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 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) +} diff --git a/API Server/internal/api/services.go b/API Server/internal/api/services.go new file mode 100644 index 0000000..7abd6b3 --- /dev/null +++ b/API Server/internal/api/services.go @@ -0,0 +1,188 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + "carcontrol/api/internal/models" +) + +func (s *Server) listServiceRecords(w http.ResponseWriter, r *http.Request) { + // Records are always scoped to a single car so access can be enforced; a + // cross-car listing would leak other users' data. + carID := r.URL.Query().Get("car") + if !s.requireCarAccess(w, r, carID, accessRead) { + return + } + q := url.Values{} + q.Set("sort", "-date") // most-recent service first + q.Set("perPage", "500") + q.Set("filter", fmt.Sprintf("car='%s'", carID)) + s.respondServiceList(w, r, q) +} + +// listCarServiceRecords serves GET /api/cars/{id}/service-records. +func (s *Server) listCarServiceRecords(w http.ResponseWriter, r *http.Request) { + carID := r.PathValue("id") + if !s.requireCarAccess(w, r, carID, accessRead) { + return + } + q := url.Values{} + q.Set("sort", "-date") + q.Set("perPage", "500") + q.Set("filter", fmt.Sprintf("car='%s'", carID)) + s.respondServiceList(w, r, q) +} + +func (s *Server) respondServiceList(w http.ResponseWriter, r *http.Request, q url.Values) { + res, err := s.pb.List(r.Context(), colServices, q) + if err != nil { + writePBError(w, err) + return + } + var recs []serviceRecord + if err := json.Unmarshal(res.Items, &recs); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + cars := newCarCache(s) + out := make([]models.ServiceRecord, 0, len(recs)) + for _, rec := range recs { + m := rec.toModel() + if car, err := cars.get(r.Context(), m.Car); err == nil { + m.ComputeDerived(car) + } + out = append(out, m) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) getServiceRecord(w http.ResponseWriter, r *http.Request) { + var rec serviceRecord + if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &rec); err != nil { + writePBError(w, err) + return + } + m := rec.toModel() + if !s.requireCarAccess(w, r, m.Car, accessRead) { + return + } + if car, err := s.carModel(r.Context(), m.Car); err == nil { + m.ComputeDerived(car) + } + writeJSON(w, http.StatusOK, m) +} + +func (s *Server) createServiceRecord(w http.ResponseWriter, r *http.Request) { + var in models.ServiceRecord + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if in.Car == "" { + writeError(w, http.StatusBadRequest, "car is required") + return + } + if in.Date.IsZero() { + writeError(w, http.StatusBadRequest, "date is required") + return + } + if !s.requireCarAccess(w, r, in.Car, accessWrite) { + return + } + + var rec serviceRecord + if err := s.pb.Create(r.Context(), colServices, servicePayload(in), &rec); err != nil { + writePBError(w, err) + return + } + m := rec.toModel() + if car, err := s.carModel(r.Context(), m.Car); err == nil { + m.ComputeDerived(car) + } + writeJSON(w, http.StatusCreated, m) +} + +func (s *Server) updateServiceRecord(w http.ResponseWriter, r *http.Request) { + var in models.ServiceRecord + if err := decodeJSON(r, &in); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + // Enforce write access via the record's existing parent car (the body's + // car field, if any, can't be used to escape into another user's car). + var existing serviceRecord + if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &existing); err != nil { + writePBError(w, err) + return + } + if !s.requireCarAccess(w, r, existing.Car, accessWrite) { + return + } + var rec serviceRecord + if err := s.pb.Update(r.Context(), colServices, r.PathValue("id"), servicePayload(in), &rec); err != nil { + writePBError(w, err) + return + } + m := rec.toModel() + if car, err := s.carModel(r.Context(), m.Car); err == nil { + m.ComputeDerived(car) + } + writeJSON(w, http.StatusOK, m) +} + +func (s *Server) deleteServiceRecord(w http.ResponseWriter, r *http.Request) { + var existing serviceRecord + if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &existing); err != nil { + writePBError(w, err) + return + } + if !s.requireCarAccess(w, r, existing.Car, accessWrite) { + return + } + if err := s.pb.Delete(r.Context(), colServices, r.PathValue("id")); err != nil { + writePBError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// carModel fetches a single car as a domain model. +func (s *Server) carModel(ctx context.Context, id string) (*models.Car, error) { + if id == "" { + return nil, fmt.Errorf("empty car id") + } + var rec carRecord + if err := s.pb.GetOne(ctx, colCars, id, &rec); err != nil { + return nil, err + } + m := rec.toModel() + return &m, nil +} + +// carCache memoizes car lookups within a single list request to avoid N+1 +// fetches when many service records share the same car. +type carCache struct { + s *Server + cache map[string]*models.Car +} + +func newCarCache(s *Server) *carCache { + return &carCache{s: s, cache: map[string]*models.Car{}} +} + +func (c *carCache) get(ctx context.Context, id string) (*models.Car, error) { + if car, ok := c.cache[id]; ok { + return car, nil + } + car, err := c.s.carModel(ctx, id) + if err != nil { + return nil, err + } + c.cache[id] = car + return car, nil +} diff --git a/API Server/internal/api/sessions.go b/API Server/internal/api/sessions.go new file mode 100644 index 0000000..0f926a3 --- /dev/null +++ b/API Server/internal/api/sessions.go @@ -0,0 +1,106 @@ +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/shares.go b/API Server/internal/api/shares.go new file mode 100644 index 0000000..547ed13 --- /dev/null +++ b/API Server/internal/api/shares.go @@ -0,0 +1,202 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +// shareRecord is the PocketBase-facing shape of a car_shares row: a grant that +// lets `user` access `car` at `permission` ("read"/"write"). +type shareRecord struct { + ID string `json:"id"` + Car string `json:"car"` + User string `json:"user"` + Permission string `json:"permission"` + Created string `json:"created"` +} + +// shareView is the API shape returned to clients: the grantee's public identity +// plus their permission on the car. +type shareView struct { + User userInfo `json:"user"` + Permission string `json:"permission"` +} + +// requireCarOwner ensures the current user owns the car in the {id} path param. +// Sharing management is owner-only. Returns the car id on success, else writes +// the response and returns "". +func (s *Server) requireCarOwner(w http.ResponseWriter, r *http.Request) string { + carID := r.PathValue("id") + level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), carID) + if err != nil { + writePBError(w, err) + return "" + } + if level != accessOwner { + writeError(w, http.StatusForbidden, "only the owner can manage sharing") + return "" + } + return carID +} + +// handleListShares serves GET /api/cars/{id}/shares (owner only). +func (s *Server) handleListShares(w http.ResponseWriter, r *http.Request) { + carID := s.requireCarOwner(w, r) + if carID == "" { + return + } + res, err := s.pb.List(r.Context(), colShares, url.Values{ + "filter": {fmt.Sprintf("car='%s'", carID)}, + "perPage": {"200"}, + }) + if err != nil { + writePBError(w, err) + return + } + var recs []shareRecord + if err := json.Unmarshal(res.Items, &recs); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out := make([]shareView, 0, len(recs)) + for _, rec := range recs { + u, err := s.fetchUser(r, rec.User) + if err != nil { + continue // grantee user was deleted; skip defensively + } + out = append(out, shareView{ + User: userInfo{ID: u.ID, Email: u.Email, Name: u.Name}, + Permission: rec.Permission, + }) + } + writeJSON(w, http.StatusOK, out) +} + +type shareRequest struct { + Email string `json:"email"` + Permission string `json:"permission"` +} + +// handleUpsertShare serves POST /api/cars/{id}/shares (owner only). It grants +// or updates a share for the user identified by email. Body: {email, +// permission}. Idempotent: an existing grant for that user is updated. +func (s *Server) handleUpsertShare(w http.ResponseWriter, r *http.Request) { + carID := s.requireCarOwner(w, r) + if carID == "" { + return + } + var in shareRequest + 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 in.Permission != accessRead && in.Permission != accessWrite { + writeError(w, http.StatusBadRequest, "permission must be 'read' or 'write'") + return + } + + target, err := s.findUserByEmail(r, in.Email) + if err != nil { + writePBError(w, err) + return + } + if target == nil { + writeError(w, http.StatusNotFound, "no user with that email") + return + } + if target.ID == s.currentUserID(r) { + writeError(w, http.StatusBadRequest, "you already own this car") + return + } + + // Upsert: update the existing grant's permission, else create a new one. + existing, err := s.findShare(r, carID, target.ID) + if err != nil { + writePBError(w, err) + return + } + payload := map[string]any{"car": carID, "user": target.ID, "permission": in.Permission} + if existing != nil { + if err := s.pb.Update(r.Context(), colShares, existing.ID, payload, nil); err != nil { + writePBError(w, err) + return + } + } else if err := s.pb.Create(r.Context(), colShares, payload, nil); err != nil { + writePBError(w, err) + return + } + + writeJSON(w, http.StatusOK, shareView{ + User: userInfo{ID: target.ID, Email: target.Email, Name: target.Name}, + Permission: in.Permission, + }) +} + +// handleDeleteShare serves DELETE /api/cars/{id}/shares/{userId} (owner only). +func (s *Server) handleDeleteShare(w http.ResponseWriter, r *http.Request) { + carID := s.requireCarOwner(w, r) + if carID == "" { + return + } + existing, err := s.findShare(r, carID, r.PathValue("userId")) + if err != nil { + writePBError(w, err) + return + } + if existing == nil { + w.WriteHeader(http.StatusNoContent) // already not shared — idempotent + return + } + if err := s.pb.Delete(r.Context(), colShares, existing.ID); err != nil { + writePBError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// findShare returns the car_shares grant for (carID, userID), or nil if none. +func (s *Server) findShare(r *http.Request, carID, userID string) (*shareRecord, error) { + res, err := s.pb.List(r.Context(), colShares, url.Values{ + "filter": {fmt.Sprintf("car='%s' && user='%s'", carID, userID)}, + "perPage": {"1"}, + }) + if err != nil { + return nil, err + } + var recs []shareRecord + if err := json.Unmarshal(res.Items, &recs); err != nil { + return nil, err + } + if len(recs) == 0 { + return nil, nil + } + return &recs[0], nil +} + +// 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{ + "filter": {fmt.Sprintf("email='%s'", strings.ReplaceAll(email, "'", ""))}, + "perPage": {"1"}, + }) + if err != nil { + return nil, err + } + var recs []userRecord + if err := json.Unmarshal(res.Items, &recs); err != nil { + return nil, err + } + if len(recs) == 0 { + return nil, nil + } + return &recs[0], nil +} diff --git a/API Server/internal/auth/jwt.go b/API Server/internal/auth/jwt.go new file mode 100644 index 0000000..3f2fae9 --- /dev/null +++ b/API Server/internal/auth/jwt.go @@ -0,0 +1,96 @@ +// 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 new file mode 100644 index 0000000..c853787 --- /dev/null +++ b/API Server/internal/config/config.go @@ -0,0 +1,95 @@ +// Package config loads server configuration from environment variables, +// optionally seeded from a .env file in the working directory. +package config + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +type Config struct { + Port string + PBURL string + PBAdminEmail string + PBAdminPasswd string + CORSOrigins []string + AuthSecret string + UsersCollection 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" + +// 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") + + 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"), + } + + if cfg.PBAdminEmail == "" || cfg.PBAdminPasswd == "" { + return nil, fmt.Errorf("PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required") + } + return cfg, nil +} + +// 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 { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func splitCSV(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + 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. +func loadDotEnv(path string) { + f, err := os.Open(path) + if err != nil { + return // .env is optional + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.Trim(strings.TrimSpace(val), `"'`) + if _, exists := os.LookupEnv(key); !exists { + os.Setenv(key, val) + } + } +} diff --git a/API Server/internal/models/models.go b/API Server/internal/models/models.go new file mode 100644 index 0000000..310392f --- /dev/null +++ b/API Server/internal/models/models.go @@ -0,0 +1,140 @@ +// Package models defines the domain types for the car maintenance tracker. +// +// The shapes mirror the original "Car Service.xlsx": one Car per sheet, a log +// of ServiceRecords (date + km, plus which parts were changed), and a per-car +// catalog of Parts (cols M/N). Derived fields follow the spreadsheet formulas: +// +// Next Service Date = Service Date + ServiceIntervalDays (Excel: A + 365) +// Next Service Km = Service Km + ServiceIntervalKm (Excel: B + 15000) +package models + +import "time" + +// Car corresponds to one worksheet in the original spreadsheet. +type Car struct { + ID string `json:"id"` + Name string `json:"name"` // e.g. "Toyota Yaris" + Make string `json:"make"` // e.g. "Toyota" + Model string `json:"model"` // e.g. "Yaris" + Year int `json:"year"` // optional + Registration string `json:"registration"` // optional plate + RegistrationCountry string `json:"registrationCountry"` // optional (country of registration) + VIN string `json:"vin"` // optional + + // Maintenance intervals, configurable per car. The spreadsheet hard-coded + // 365 days and 15000 km; here they are stored so each car can differ. + ServiceIntervalDays int `json:"serviceIntervalDays"` + ServiceIntervalKm int `json:"serviceIntervalKm"` + + // CurrentKm is the car's present odometer reading, updated by the user. Used + // to flag km-based overdue service (current_km >= last service km + interval). + CurrentKm int `json:"currentKm"` + + OilSpec string `json:"oilSpec"` // e.g. "Toyota Advanced Fuel Economy 0W20" + TransmissionOilSpec string `json:"transmissionOilSpec"` // e.g. "Toyota WS" + DifferentialOilSpec string `json:"differentialOilSpec"` // e.g. "SAE 75W-90 GL-5" + BrakeFluidSpec string `json:"brakeFluidSpec"` // e.g. "DOT 4" + CoolantSpec string `json:"coolantSpec"` // e.g. "Toyota Super Long Life Coolant" + + FuelType string `json:"fuelType"` // petrol | diesel | hybrid | electric + BuildDate string `json:"buildDate"` // ISO YYYY-MM-DD (date-only) + FirstRegistrationDate string `json:"firstRegistrationDate"` // ISO YYYY-MM-DD (date-only) + + // Owner is the user id that owns this car. Access is the requesting user's + // permission on it — "owner", "write", or "read" — computed by the API at + // read time and never persisted (omitempty; not part of the write payload). + Owner string `json:"owner,omitempty"` + Access string `json:"access,omitempty"` + + Created string `json:"created,omitempty"` + Updated string `json:"updated,omitempty"` +} + +// ServiceRecord is one row of the Service log for a car. +type ServiceRecord struct { + ID string `json:"id"` + Car string `json:"car"` // relation -> Car.ID + Date time.Time `json:"date"` // service date (Excel col A) + Km int `json:"km"` // odometer at service (Excel col B) + + // "Changed Parts" checkboxes (Excel cols E/F/G). + ChangedOil bool `json:"changedOil"` // Oil & Oil Filter + ChangedEngineAirFilter bool `json:"changedEngineAirFilter"` // Engine Air Filter + ChangedCabinAirFilter bool `json:"changedCabinAirFilter"` // Cabin Air Filter + + Notes string `json:"notes,omitempty"` + + // Derived (not stored): filled in by the API on read. + NextServiceDate *time.Time `json:"nextServiceDate,omitempty"` // Excel col C + NextServiceKm *int `json:"nextServiceKm,omitempty"` // Excel col D + + Created string `json:"created,omitempty"` + Updated string `json:"updated,omitempty"` +} + +// Part is one entry in a car's parts catalog (Excel cols M/N). +type Part struct { + ID string `json:"id"` + Car string `json:"car"` // relation -> Car.ID + Name string `json:"name"` // e.g. "Oil Filter" + PartNumber string `json:"partNumber"` // e.g. "04152-YZZA7" + Category string `json:"category"` // optional: oil|filter|wiper|other + + Created string `json:"created,omitempty"` + Updated string `json:"updated,omitempty"` +} + +// User is the authenticated account's profile, covering the Settings panel's +// Account/Profile/Appearance sections. +type User struct { + ID string `json:"id"` + Email string `json:"email"` + Verified bool `json:"verified"` + Name string `json:"name"` + Bio string `json:"bio"` + + HasAvatar bool `json:"hasAvatar"` + + Theme string `json:"theme"` // light | dark | system + Locale string `json:"locale"` // e.g. "en-US" + DateFormat string `json:"dateFormat"` // YMD | DMY | MDY + FontSize string `json:"fontSize"` // small | medium | large + Role string `json:"role"` // user | admin + + // Non-empty while an account-deletion request is pending its cooldown. + DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"` + + Created string `json:"created,omitempty"` +} + +// Session is one active login (device) for the current user. +type Session struct { + ID string `json:"id"` + DeviceLabel string `json:"deviceLabel"` + IP string `json:"ip"` + Current bool `json:"current"` + Created time.Time `json:"created"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// ComputeDerived fills NextServiceDate / NextServiceKm from the car's intervals, +// reproducing the spreadsheet formulas. Intervals of 0 fall back to the +// spreadsheet defaults (365 days, 15000 km). +func (r *ServiceRecord) ComputeDerived(c *Car) { + days := c.ServiceIntervalDays + if days <= 0 { + days = 365 + } + km := c.ServiceIntervalKm + if km <= 0 { + km = 15000 + } + if !r.Date.IsZero() { + d := r.Date.AddDate(0, 0, days) + r.NextServiceDate = &d + } + if r.Km > 0 { + n := r.Km + km + r.NextServiceKm = &n + } +} diff --git a/API Server/internal/pb/client.go b/API Server/internal/pb/client.go new file mode 100644 index 0000000..28124d0 --- /dev/null +++ b/API Server/internal/pb/client.go @@ -0,0 +1,362 @@ +// Package pb is a small PocketBase REST client. The API Server is the only +// component that talks to PocketBase, so all database access funnels through +// here. It authenticates as a superuser and performs CRUD on collection records. +package pb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "sync" + "time" +) + +// Client is a concurrency-safe PocketBase REST client with auto re-auth. +type Client struct { + baseURL string + email string + password string + http *http.Client + + mu sync.RWMutex + token string +} + +func New(baseURL, email, password string) *Client { + return &Client{ + baseURL: baseURL, + email: email, + password: password, + http: &http.Client{Timeout: 15 * time.Second}, + } +} + +// APIError carries the HTTP status and body from a failed PocketBase call. +type APIError struct { + Status int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("pocketbase: status %d: %s", e.Status, e.Body) +} + +// 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 { + body, _ := json.Marshal(map[string]string{ + "identity": c.email, + "password": c.password, + }) + + endpoints := []string{ + "/api/collections/_superusers/auth-with-password", + "/api/admins/auth-with-password", + } + + var lastErr error + for _, ep := range endpoints { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+ep, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return 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 { + return err + } + c.mu.Lock() + c.token = out.Token + c.mu.Unlock() + return nil + } + lastErr = &APIError{Status: resp.StatusCode, Body: string(raw)} + } + return fmt.Errorf("authentication failed: %w", lastErr) +} + +func (c *Client) currentToken() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.token +} + +// do executes a request, attaching the auth token. On a 401 it re-authenticates +// once and retries. +func (c *Client) do(ctx context.Context, method, path string, payload any) ([]byte, error) { + raw, status, err := c.attempt(ctx, method, path, payload) + if err != nil { + return nil, err + } + if status == http.StatusUnauthorized { + if err := c.Authenticate(ctx); err != nil { + return nil, err + } + raw, status, err = c.attempt(ctx, method, path, payload) + if err != nil { + return nil, err + } + } + if status < 200 || status >= 300 { + return nil, &APIError{Status: status, Body: string(raw)} + } + return raw, nil +} + +func (c *Client) attempt(ctx context.Context, method, path string, payload any) ([]byte, int, error) { + var reader io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return nil, 0, err + } + reader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return nil, 0, err + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if tok := c.currentToken(); tok != "" { + req.Header.Set("Authorization", tok) + } + + 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 +} + +// AuthRecord is the user record returned by a successful password auth. +type AuthRecord struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` +} + +// AuthWithPassword verifies a user's credentials against an auth collection +// (e.g. "users"). This is an unauthenticated PocketBase call — it does not use +// 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" + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, &APIError{Status: resp.StatusCode, Body: string(raw)} + } + var out struct { + Record AuthRecord `json:"record"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return &out.Record, nil +} + +// ListResult is the envelope PocketBase returns from list endpoints. +type ListResult struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Items json.RawMessage `json:"items"` +} + +// List fetches records from a collection. The query (filter, sort, perPage, +// expand, ...) is passed through as URL query parameters. +func (c *Client) List(ctx context.Context, collection string, query url.Values) (*ListResult, error) { + path := "/api/collections/" + collection + "/records" + if len(query) > 0 { + path += "?" + query.Encode() + } + raw, err := c.do(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + var lr ListResult + if err := json.Unmarshal(raw, &lr); err != nil { + return nil, err + } + return &lr, nil +} + +// GetOne fetches a single record by id and unmarshals it into dest. +func (c *Client) GetOne(ctx context.Context, collection, id string, dest any) error { + raw, err := c.do(ctx, http.MethodGet, "/api/collections/"+collection+"/records/"+id, nil) + if err != nil { + return err + } + return json.Unmarshal(raw, dest) +} + +// Create inserts a record and unmarshals the created record into dest. +func (c *Client) Create(ctx context.Context, collection string, payload any, dest any) error { + raw, err := c.do(ctx, http.MethodPost, "/api/collections/"+collection+"/records", payload) + if err != nil { + return err + } + if dest != nil { + return json.Unmarshal(raw, dest) + } + return nil +} + +// Update patches a record and unmarshals the updated record into dest. +func (c *Client) Update(ctx context.Context, collection, id string, payload any, dest any) error { + raw, err := c.do(ctx, http.MethodPatch, "/api/collections/"+collection+"/records/"+id, payload) + if err != nil { + return err + } + if dest != nil { + return json.Unmarshal(raw, dest) + } + return nil +} + +// Delete removes a record by id. +func (c *Client) Delete(ctx context.Context, collection, id string) error { + _, err := c.do(ctx, http.MethodDelete, "/api/collections/"+collection+"/records/"+id, nil) + return err +} + +// GetFile downloads a file field's stored content, authenticating as the +// superuser (this project's collections have no public access rules). +func (c *Client) GetFile(ctx context.Context, collection, recordID, filename string) ([]byte, string, error) { + path := "/api/files/" + collection + "/" + recordID + "/" + filename + raw, ctype, status, err := c.attemptGetFile(ctx, path) + if err != nil { + return nil, "", err + } + if status == http.StatusUnauthorized { + if aerr := c.Authenticate(ctx); aerr != nil { + return nil, "", aerr + } + raw, ctype, status, err = c.attemptGetFile(ctx, path) + if err != nil { + return nil, "", err + } + } + if status < 200 || status >= 300 { + return nil, "", &APIError{Status: status, Body: string(raw)} + } + return raw, ctype, nil +} + +func (c *Client) attemptGetFile(ctx context.Context, path string) ([]byte, string, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return nil, "", 0, err + } + if tok := c.currentToken(); tok != "" { + req.Header.Set("Authorization", tok) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, "", 0, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", 0, err + } + return raw, resp.Header.Get("Content-Type"), resp.StatusCode, nil +} + +// RequestVerification asks PocketBase to send a verification email for the +// given address (a public PocketBase endpoint; it always "succeeds" whether or +// not the email exists, to avoid leaking account existence — and it only +// actually sends mail if the PocketBase instance has SMTP configured). +func (c *Client) RequestVerification(ctx context.Context, collection, email string) error { + _, err := c.do(ctx, http.MethodPost, "/api/collections/"+collection+"/request-verification", map[string]string{"email": email}) + return err +} + +// UpdateMultipart patches a record via multipart/form-data — required for file +// fields (e.g. the users.avatar upload), which PocketBase doesn't accept as +// plain JSON. Pass fileField == "" to send only the plain fields. +func (c *Client) UpdateMultipart(ctx context.Context, collection, id string, fields map[string]string, fileField, filename string, fileContent []byte) error { + status, err := c.attemptMultipart(ctx, collection, id, fields, fileField, filename, fileContent) + if status == http.StatusUnauthorized { + if aerr := c.Authenticate(ctx); aerr != nil { + return aerr + } + _, err = c.attemptMultipart(ctx, collection, id, fields, fileField, filename, fileContent) + } + return err +} + +func (c *Client) attemptMultipart(ctx context.Context, collection, id string, fields map[string]string, fileField, filename string, fileContent []byte) (int, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + for k, v := range fields { + if err := mw.WriteField(k, v); err != nil { + return 0, err + } + } + if fileField != "" { + fw, err := mw.CreateFormFile(fileField, filename) + if err != nil { + return 0, err + } + if _, err := fw.Write(fileContent); err != nil { + return 0, err + } + } + if err := mw.Close(); err != nil { + return 0, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+"/api/collections/"+collection+"/records/"+id, &buf) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + if tok := c.currentToken(); tok != "" { + req.Header.Set("Authorization", tok) + } + + resp, err := c.http.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return resp.StatusCode, &APIError{Status: resp.StatusCode, Body: string(raw)} + } + return resp.StatusCode, nil +} diff --git a/API Server/main.go b/API Server/main.go new file mode 100644 index 0000000..1b4915f --- /dev/null +++ b/API Server/main.go @@ -0,0 +1,79 @@ +// 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/index.html b/API Server/panel/index.html new file mode 100644 index 0000000..383a83d --- /dev/null +++ b/API Server/panel/index.html @@ -0,0 +1,14 @@ + + + + + + + + DriverVault · API Server + + +
+ + + diff --git a/API Server/panel/package-lock.json b/API Server/panel/package-lock.json new file mode 100644 index 0000000..f2f594a --- /dev/null +++ b/API Server/panel/package-lock.json @@ -0,0 +1,1964 @@ +{ + "name": "drivervault-api-panel", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "drivervault-api-panel", + "version": "1.0.0", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/API Server/panel/package.json b/API Server/panel/package.json new file mode 100644 index 0000000..4241424 --- /dev/null +++ b/API Server/panel/package.json @@ -0,0 +1,20 @@ +{ + "name": "drivervault-api-panel", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } +} diff --git a/API Server/panel/public/favicon.svg b/API Server/panel/public/favicon.svg new file mode 100644 index 0000000..079bcb7 --- /dev/null +++ b/API Server/panel/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/API Server/panel/src/App.vue b/API Server/panel/src/App.vue new file mode 100644 index 0000000..8f5eabd --- /dev/null +++ b/API Server/panel/src/App.vue @@ -0,0 +1,196 @@ + + + diff --git a/API Server/panel/src/components/EndpointTable.vue b/API Server/panel/src/components/EndpointTable.vue new file mode 100644 index 0000000..8b83391 --- /dev/null +++ b/API Server/panel/src/components/EndpointTable.vue @@ -0,0 +1,44 @@ + + + diff --git a/API Server/panel/src/main.js b/API Server/panel/src/main.js new file mode 100644 index 0000000..40dbaaa --- /dev/null +++ b/API Server/panel/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import "./style.css"; +import "./theme"; + +createApp(App).mount("#app"); diff --git a/API Server/panel/src/style.css b/API Server/panel/src/style.css new file mode 100644 index 0000000..7373cd9 --- /dev/null +++ b/API Server/panel/src/style.css @@ -0,0 +1,219 @@ +@import "tailwindcss"; + +/* DriverVault webfonts — Archivo (UI + display, italic 800 is the brand voice) + DM Mono (data/labels). */ +@import url("https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;0,700;0,800;1,700;1,800&family=DM+Mono:ital,wght@0,400;0,500&display=swap"); + +/* Tailwind v4 defaults dark: to prefers-color-scheme; switch to a class strategy + so theme.js can toggle `.dark` on . */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* ============================================================================ + DriverVault design tokens (subset used by the API panel). Raw ramps + semantic + aliases; html.dark re-points the aliases so utilities flip for free. + ========================================================================== */ +: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: #ffffff; + + --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 rgba(15, 30, 61, 0.06); + --shadow-sm: 0 1px 2px rgba(15, 30, 61, 0.04), 0 2px 6px rgba(15, 30, 61, 0.06); + --shadow-md: 0 1px 2px rgba(15, 30, 61, 0.04), 0 12px 30px -12px rgba(15, 30, 61, 0.14); + + --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 rgba(0, 0, 0, 0.4); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.4); + --shadow-md: 0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 30px -12px rgba(0, 0, 0, 0.55); +} + +/* ============================================================================ + Map tokens into Tailwind's theme so semantic utilities exist and flip in dark + mode without a dark: variant. Utility names mirror the DriverVault web app. + ========================================================================== */ +@theme inline { + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + + --color-brand-300: var(--brand-300); + --color-brand-400: var(--brand-400); + --color-brand-500: var(--brand-500); + --color-brand-600: var(--brand-600); + --color-brand-700: var(--brand-700); + --color-brand-900: var(--brand-900); + + --color-page: var(--surface-page); + --color-card: var(--surface-card); + --color-sunken: var(--surface-sunken); + + --color-strong: var(--text-strong); + --color-body: var(--text-body); + --color-muted: var(--text-muted); + --color-brandtext: var(--text-brand); + + --color-subtle: var(--border-subtle); + --color-default: var(--border-default); + --color-strongline: var(--border-strong); + + --color-accent: var(--accent); + --color-accent-hover: var(--accent-hover); + + --color-success: var(--success-600); + --color-success-soft: var(--success-100); + --color-warning: var(--warning-600); + --color-warning-soft: var(--warning-100); + --color-danger: var(--danger-600); + --color-danger-soft: var(--danger-100); + --color-info: var(--info-600); + --color-info-soft: var(--info-100); + + --radius-control: 12px; + --radius-card: 18px; + --radius-pill: 999px; + + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow-card: var(--shadow-md); +} + +html, +body, +#app { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + background: var(--surface-page); + color: var(--text-body); + -webkit-font-smoothing: antialiased; +} + +/* Small mono eyebrow labels (uppercase, tracked out). */ +.eyebrow { + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 11px; + color: var(--text-muted); +} + +/* Monospaced data (methods, paths, latencies) so columns align. */ +.data { + font-family: var(--font-mono); + letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; +} + +/* Secondary button (theme toggle). */ +.dh-btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 34px; + padding: 0 12px; + 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-weight: 600; + font-size: 0.8125rem; + cursor: pointer; + transition: background-color 140ms ease, border-color 140ms ease; +} +.dh-btn-ghost:hover { + background: var(--surface-sunken); + border-color: var(--border-strong); +} +.dh-btn-ghost:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.dh-card { + border: 1px solid var(--border-subtle); + background: var(--surface-card); + border-radius: var(--radius-card); + box-shadow: var(--shadow-sm); +} diff --git a/API Server/panel/src/theme.js b/API Server/panel/src/theme.js new file mode 100644 index 0000000..7ce531b --- /dev/null +++ b/API Server/panel/src/theme.js @@ -0,0 +1,35 @@ +import { ref } from "vue"; + +// Persisted light/dark theme for the panel. Matches the DriverVault web app's +// class strategy (`.dark` on ), but under its own storage key so it +// doesn't collide with the main app's `theme` preference. +const KEY = "dh-panel-theme"; + +function initial() { + try { + const saved = localStorage.getItem(KEY); + if (saved === "dark" || saved === "light") return saved; + } catch { + /* private mode */ + } + // Fall back to the OS preference. + return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export const theme = ref(initial()); + +export function applyTheme(t) { + theme.value = t; + document.documentElement.classList.toggle("dark", t === "dark"); + try { + localStorage.setItem(KEY, t); + } catch { + /* private mode — theme just won't persist */ + } +} + +export function toggleTheme() { + applyTheme(theme.value === "dark" ? "light" : "dark"); +} + +applyTheme(theme.value); diff --git a/API Server/panel/vite.config.js b/API Server/panel/vite.config.js new file mode 100644 index 0000000..c5d5b11 --- /dev/null +++ b/API Server/panel/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import tailwindcss from "@tailwindcss/vite"; + +// Builds into internal/api/dist, which the Go server embeds via go:embed +// and serves at the server root (the DriverVault API panel). +export default defineConfig({ + plugins: [vue(), tailwindcss()], + build: { + outDir: "../internal/api/dist", + emptyOutDir: true, + }, + server: { + port: 5174, + proxy: { + // Dev-mode proxy to a locally running API Server. + "/api": "http://localhost:8080", + }, + }, +}); diff --git a/API Server/scripts/backfill-car-owners.mjs b/API Server/scripts/backfill-car-owners.mjs new file mode 100644 index 0000000..aa24d59 --- /dev/null +++ b/API Server/scripts/backfill-car-owners.mjs @@ -0,0 +1,82 @@ +// One-off backfill: assign an owner to every car that has none. +// +// Before per-user ownership existed, all cars were ownerless and globally +// visible. This sets `owner` on any car missing it so those cars keep showing +// up once the API Server starts filtering by owner. Safe to re-run: cars that +// already have an owner are skipped. +// +// Usage (PowerShell): +// $env:PB_URL="http://10.2.1.10:8027" +// $env:PB_ADMIN_EMAIL="admin@carcontrole.local" +// $env:PB_ADMIN_PASSWORD="..." +// node scripts/backfill-car-owners.mjs +// +// Defaults to Dariusz (u7jxozp9jbspp5r) when omitted. + +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 OWNER_ID = process.argv[2] || "u7jxozp9jbspp5r"; // Dariusz + +if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD."); + process.exit(1); +} + +async function authenticate() { + for (const ep of [ + "/api/collections/_superusers/auth-with-password", + "/api/admins/auth-with-password", + ]) { + const res = await fetch(PB_URL + ep, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + }); + if (res.ok) return (await res.json()).token; + } + throw new Error("Admin authentication failed."); +} + +async function main() { + console.log(`Connecting to ${PB_URL} ...`); + const token = await authenticate(); + + // Verify the target owner exists, so we fail loudly on a bad id. + const ures = await fetch(`${PB_URL}/api/collections/users/records/${OWNER_ID}`, { + headers: { Authorization: token }, + }); + if (!ures.ok) throw new Error(`owner user ${OWNER_ID} not found (${ures.status})`); + const owner = await ures.json(); + console.log(`Backfilling ownerless cars → ${owner.email} (${OWNER_ID})`); + + const res = await fetch(`${PB_URL}/api/collections/cars/records?perPage=500`, { + headers: { Authorization: token }, + }); + if (!res.ok) throw new Error(`list cars failed: ${res.status} ${await res.text()}`); + const cars = (await res.json()).items || []; + + let updated = 0; + for (const car of cars) { + if (car.owner) { + console.log(`• ${car.name} — already owned (${car.owner})`); + continue; + } + const upd = await fetch(`${PB_URL}/api/collections/cars/records/${car.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: token }, + body: JSON.stringify({ owner: OWNER_ID }), + }); + if (!upd.ok) throw new Error(`update ${car.name} failed: ${upd.status} ${await upd.text()}`); + console.log(`✓ ${car.name} — owner set`); + updated++; + } + + console.log(`\nDone. ${updated} car(s) updated, ${cars.length - updated} already owned.`); +} + +main().catch((err) => { + console.error("\nBackfill failed:", err.message); + process.exit(1); +}); diff --git a/API Server/scripts/create-user.mjs b/API Server/scripts/create-user.mjs new file mode 100644 index 0000000..0c61ee2 --- /dev/null +++ b/API Server/scripts/create-user.mjs @@ -0,0 +1,76 @@ +// Create (or report) an app user in the PocketBase "users" collection, using +// superuser credentials. App users log in to the web/phone apps via the API +// Server's /api/auth/login. +// +// Usage (PowerShell): +// $env:PB_URL="http://10.2.1.10:8027" +// $env:PB_ADMIN_EMAIL="admin@carcontrole.local" +// $env:PB_ADMIN_PASSWORD="..." +// node scripts/create-user.mjs [name] + +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 COLLECTION = process.env.AUTH_USERS_COLLECTION || "users"; + +const [, , email, password, name] = process.argv; + +if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD."); + process.exit(1); +} +if (!email || !password) { + console.error("Usage: node scripts/create-user.mjs [name]"); + process.exit(1); +} +if (password.length < 8) { + console.error("Password must be at least 8 characters (PocketBase requirement)."); + process.exit(1); +} + +async function authenticate() { + for (const ep of [ + "/api/collections/_superusers/auth-with-password", + "/api/admins/auth-with-password", + ]) { + const res = await fetch(PB_URL + ep, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + }); + if (res.ok) return (await res.json()).token; + } + throw new Error("Admin authentication failed."); +} + +async function main() { + const token = await authenticate(); + const res = await fetch(PB_URL + `/api/collections/${COLLECTION}/records`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: token }, + body: JSON.stringify({ + email, + password, + passwordConfirm: password, + name: name || email.split("@")[0], + emailVisibility: true, + verified: true, + }), + }); + const text = await res.text(); + if (!res.ok) { + if (text.includes("validation_not_unique") || res.status === 400) { + console.error(`Could not create user (maybe it already exists):\n${text}`); + } else { + console.error(`Failed: ${res.status} ${text}`); + } + process.exit(1); + } + console.log(`✓ User created: ${email}`); + console.log(" Log in via the web app or POST /api/auth/login."); +} + +main().catch((e) => { + console.error("Error:", e.message); + process.exit(1); +}); diff --git a/API Server/scripts/seed_from_excel.py b/API Server/scripts/seed_from_excel.py new file mode 100644 index 0000000..10e195d --- /dev/null +++ b/API Server/scripts/seed_from_excel.py @@ -0,0 +1,156 @@ +"""Seed the Car Control database from the original "Car Service.xlsx". + +Imports each car sheet (service log + parts catalog) by POSTing through the +API Server, so the full stack (client -> API Server -> PocketBase) is exercised. + +Usage: + pip install openpyxl requests + set API_BASE=http://localhost:8080/api (default) + python scripts/seed_from_excel.py "C:/Users/jania/Desktop/Car Service.xlsx" + +Idempotency: a car is created only if no car with the same name exists yet. +""" + +import os +import sys +import datetime as dt + +# Force UTF-8 stdout so console output works on Windows code pages (cp1250). +sys.stdout.reconfigure(encoding="utf-8") + +import requests +import openpyxl + +API_BASE = os.environ.get("API_BASE", "http://localhost:8080/api").rstrip("/") +DEFAULT_XLSX = r"C:/Users/jania/Desktop/Car Service.xlsx" + + +def yesno(v): + return isinstance(v, str) and v.strip().lower() == "yes" + + +def split_make_model(title): + parts = title.split(" ", 1) + if len(parts) == 2: + return parts[0], parts[1] + return title, "" + + +def get_existing_cars(): + r = requests.get(f"{API_BASE}/cars", timeout=15) + r.raise_for_status() + return {c["name"]: c for c in r.json()} + + +def create_car(name, oil_spec): + make, model = split_make_model(name) + payload = { + "name": name, + "make": make, + "model": model, + "serviceIntervalDays": 365, + "serviceIntervalKm": 15000, + "oilSpec": oil_spec or "", + } + r = requests.post(f"{API_BASE}/cars", json=payload, timeout=15) + r.raise_for_status() + return r.json() + + +def create_service(car_id, date, km, e, f, g): + payload = { + "car": car_id, + "date": date.strftime("%Y-%m-%dT00:00:00Z"), + "km": int(km) if km else 0, + "changedOil": yesno(e), + "changedEngineAirFilter": yesno(f), + "changedCabinAirFilter": yesno(g), + } + r = requests.post(f"{API_BASE}/service-records", json=payload, timeout=15) + r.raise_for_status() + return r.json() + + +def create_part(car_id, name, number): + payload = {"car": car_id, "name": str(name), "partNumber": str(number) if number else ""} + r = requests.post(f"{API_BASE}/parts", json=payload, timeout=15) + r.raise_for_status() + return r.json() + + +def seed_service_sheet(ws, existing): + """Sheets like 'Toyota Yaris' / 'Toyota Avensis': service log + parts (M/N).""" + name = ws.title + + # Oil spec lives in N2 (with the oil-filter etc. catalog below in M/N). + oil_spec = ws["N2"].value + if isinstance(oil_spec, str): + oil_spec = " / ".join(line.strip() for line in oil_spec.splitlines() if line.strip()) + + if name in existing: + print(f"• {name} — car exists, skipping") + return + car = create_car(name, oil_spec) + cid = car["id"] + print(f"✓ {name} — car created ({cid})") + + services = parts = 0 + for row in range(4, ws.max_row + 1): + date = ws.cell(row=row, column=1).value # A + if isinstance(date, dt.datetime): + km = ws.cell(row=row, column=2).value # B + create_service( + cid, date, km, + ws.cell(row=row, column=5).value, # E + ws.cell(row=row, column=6).value, # F + ws.cell(row=row, column=7).value, # G + ) + services += 1 + + pname = ws.cell(row=row, column=13).value # M + pnum = ws.cell(row=row, column=14).value # N + if pname and row >= 3: # M3 onward are real parts (M2/N2 = oil header) + create_part(cid, pname, pnum) + parts += 1 + + print(f" {services} service records, {parts} parts") + + +def seed_reference_sheet(ws, existing): + """Small sheets like 'Corolla Mama': just a parts reference (A/B columns).""" + name = ws.title + if name in existing: + print(f"• {name} — car exists, skipping") + return + car = create_car(name, None) + cid = car["id"] + print(f"✓ {name} — car created ({cid})") + parts = 0 + for row in range(1, ws.max_row + 1): + pname = ws.cell(row=row, column=1).value # A + pnum = ws.cell(row=row, column=2).value # B + if pname: + create_part(cid, pname, pnum) + parts += 1 + print(f" {parts} parts") + + +def main(): + path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_XLSX + print(f"Reading {path}") + print(f"Seeding via {API_BASE}") + wb = openpyxl.load_workbook(path, data_only=True) + existing = get_existing_cars() + + for ws in wb.worksheets: + # Heuristic: a service sheet has the "Service" header layout (>= col G). + if ws.max_column >= 7 and ws["A3"].value == "Date": + seed_service_sheet(ws, existing) + else: + seed_reference_sheet(ws, existing) + + print("\nSeed complete.") + + +if __name__ == "__main__": + main() diff --git a/API Server/scripts/set-role.mjs b/API Server/scripts/set-role.mjs new file mode 100644 index 0000000..37b5f00 --- /dev/null +++ b/API Server/scripts/set-role.mjs @@ -0,0 +1,63 @@ +// Set a user's access role ("user" or "admin") by email. +// +// 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 + +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 [, , email, role] = process.argv; + +if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD."); + process.exit(1); +} +if (!email || !role || !["user", "admin"].includes(role)) { + console.error("Usage: node scripts/set-role.mjs "); + process.exit(1); +} + +async function authenticate() { + for (const ep of [ + "/api/collections/_superusers/auth-with-password", + "/api/admins/auth-with-password", + ]) { + const res = await fetch(PB_URL + ep, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + }); + if (res.ok) return (await res.json()).token; + } + throw new Error("Admin authentication failed."); +} + +async function main() { + console.log(`Connecting to ${PB_URL} ...`); + const token = await authenticate(); + + const q = new URLSearchParams({ filter: `email='${email.replace(/'/g, "")}'`, perPage: "1" }); + const res = await fetch(`${PB_URL}/api/collections/users/records?${q}`, { + headers: { Authorization: token }, + }); + if (!res.ok) throw new Error(`lookup failed: ${res.status} ${await res.text()}`); + const items = (await res.json()).items || []; + if (items.length === 0) throw new Error(`no user with email ${email}`); + + const upd = await fetch(`${PB_URL}/api/collections/users/records/${items[0].id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: token }, + body: JSON.stringify({ role }), + }); + if (!upd.ok) throw new Error(`update failed: ${upd.status} ${await upd.text()}`); + console.log(`✓ ${email} role set to "${role}"`); +} + +main().catch((err) => { + console.error("\nFailed:", err.message); + process.exit(1); +}); diff --git a/API Server/scripts/setup-pocketbase.mjs b/API Server/scripts/setup-pocketbase.mjs new file mode 100644 index 0000000..85ad12e --- /dev/null +++ b/API Server/scripts/setup-pocketbase.mjs @@ -0,0 +1,311 @@ +// Idempotent PocketBase schema setup for the Car Control project. +// +// Creates three collections — cars, service_records, parts — matching the +// original "Car Service.xlsx". Access rules are left admin-only (null) on +// purpose: every client goes through the API Server, which authenticates as a +// superuser, so the database is never exposed directly. +// +// Usage (PowerShell): +// $env:PB_URL="http://10.2.1.10:8027" +// $env:PB_ADMIN_EMAIL="you@example.com" +// $env:PB_ADMIN_PASSWORD="secret" +// node scripts/setup-pocketbase.mjs +// +// Re-running is safe: existing collections are skipped. + +const PB_URL = (process.env.PB_URL || "http://10.2.1.10:8027").replace(/\/+$/, ""); +const EMAIL = process.env.PB_ADMIN_EMAIL; +const PASSWORD = process.env.PB_ADMIN_PASSWORD; + +if (!EMAIL || !PASSWORD) { + console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables."); + process.exit(1); +} + +async function authenticate() { + const endpoints = [ + "/api/collections/_superusers/auth-with-password", + "/api/admins/auth-with-password", + ]; + for (const ep of endpoints) { + const res = await fetch(PB_URL + ep, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: EMAIL, password: PASSWORD }), + }); + if (res.ok) { + const data = await res.json(); + return data.token; + } + } + throw new Error("Authentication failed. Check PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD."); +} + +async function listCollections(token) { + const res = await fetch(PB_URL + "/api/collections?perPage=200", { + headers: { Authorization: token }, + }); + if (!res.ok) throw new Error(`list collections failed: ${res.status} ${await res.text()}`); + const data = await res.json(); + return Array.isArray(data) ? data : data.items || []; +} + +// Detects whether this PocketBase version serializes fields under "fields" +// (v0.23+) or the legacy "schema" key. +function detectFormat(collections) { + for (const c of collections) { + if (Array.isArray(c.fields)) return "fields"; + if (Array.isArray(c.schema)) return "schema"; + } + return "fields"; // default to modern format +} + +// Field builders normalized to {name,type,required,relTo}. They are rendered +// into the right wire shape per detected format. +const F = { + text: (name, required = false) => ({ name, type: "text", required }), + number: (name) => ({ name, type: "number", required: false }), + bool: (name) => ({ name, type: "bool", required: false }), + date: (name, required = false) => ({ name, type: "date", required }), + relation: (name, relTo, required = false, cascadeDelete = true) => ({ name, type: "relation", required, relTo, cascadeDelete }), + select: (name, values, required = false) => ({ name, type: "select", required, values }), + autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }), +}; + +function renderField(def, format, idByName) { + if (format === "schema") { + // Legacy: options nested under "options". + const options = {}; + if (def.type === "relation") { + options.collectionId = idByName[def.relTo]; + options.cascadeDelete = def.cascadeDelete !== false; + options.maxSelect = 1; + options.minSelect = 0; + } + if (def.type === "select") { + options.values = def.values; + options.maxSelect = 1; + } + return { name: def.name, type: def.type, required: def.required, options }; + } + // Modern: options flattened onto the field. + const field = { name: def.name, type: def.type, required: def.required }; + if (def.type === "relation") { + field.collectionId = idByName[def.relTo]; + field.cascadeDelete = def.cascadeDelete !== false; + field.maxSelect = 1; + field.minSelect = 0; + } + if (def.type === "select") { + field.values = def.values; + field.maxSelect = 1; + } + if (def.type === "autodate") { + field.onCreate = def.onCreate; + field.onUpdate = def.onUpdate; + } + return field; +} + +async function createCollection(token, name, defs, format, idByName) { + const rendered = defs.map((d) => renderField(d, format, idByName)); + const body = { + name, + type: "base", + [format]: rendered, // "fields" or "schema" + // Rules left null => superuser-only access (API Server is the only client). + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + }; + const res = await fetch(PB_URL + "/api/collections", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: token }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`create ${name} failed: ${res.status} ${await res.text()}`); + const created = await res.json(); + idByName[name] = created.id; + return created; +} + +async function getCollection(token, idOrName) { + const res = await fetch(PB_URL + "/api/collections/" + idOrName, { + headers: { Authorization: token }, + }); + if (!res.ok) throw new Error(`get ${idOrName} failed: ${res.status} ${await res.text()}`); + return res.json(); +} + +// reconcileFields brings an existing collection's schema in line with the desired +// definition: it appends any missing fields AND updates relation options +// (currently cascadeDelete) and select options (the "values" list) on existing +// fields. Existing field ids/data are kept. Safe to re-run as the schema +// evolves (e.g. adding cars.current_km, enabling cascade delete, or adding a +// new select choice like a date-format option). +async function reconcileFields(token, name, defs, format, idByName) { + const col = await getCollection(token, name); + const current = col[format] || []; + const byName = new Map(current.map((f) => [f.name, f])); + + const changes = []; + + // Update relation cascadeDelete and select values on existing fields to match desired. + const merged = current.map((f) => { + const def = defs.find((d) => d.name === f.name); + if (def && def.type === "relation") { + const wantCascade = def.cascadeDelete !== false; + if (f.cascadeDelete !== wantCascade) { + changes.push(`${f.name}.cascadeDelete=${wantCascade}`); + return { ...f, cascadeDelete: wantCascade }; + } + } + if (def && def.type === "select") { + const same = + Array.isArray(f.values) && + f.values.length === def.values.length && + def.values.every((v) => f.values.includes(v)); + if (!same) { + changes.push(`${f.name}.values=[${def.values.join(",")}]`); + return { ...f, values: def.values }; + } + } + return f; + }); + + // Append missing fields. + const missing = defs.filter((d) => !byName.has(d.name)); + for (const d of missing) { + merged.push(renderField(d, format, idByName)); + changes.push(`+${d.name}`); + } + + if (changes.length === 0) { + console.log(`• ${name} — up to date`); + return; + } + const res = await fetch(PB_URL + "/api/collections/" + col.id, { + method: "PATCH", + headers: { "Content-Type": "application/json", Authorization: token }, + body: JSON.stringify({ [format]: merged }), + }); + if (!res.ok) throw new Error(`update ${name} failed: ${res.status} ${await res.text()}`); + console.log(`✓ ${name} — ${changes.join(", ")}`); +} + +// Desired schema. Edit here to evolve collections; re-run the script to apply. +const DESIRED = { + cars: [ + F.text("name", true), + F.text("make"), + F.text("model"), + F.number("year"), + F.text("registration"), + F.text("registration_country"), + F.text("vin"), + F.number("service_interval_days"), + F.number("service_interval_km"), + F.text("oil_spec"), + F.text("transmission_oil_spec"), + F.text("differential_oil_spec"), + F.text("brake_fluid_spec"), + F.text("coolant_spec"), + F.number("current_km"), + F.select("fuel_type", ["petrol", "diesel", "hybrid", "electric"]), + F.text("build_date"), // ISO YYYY-MM-DD (date-only; VIN 10th digit ≈ model year) + F.text("first_registration_date"), // ISO YYYY-MM-DD + // Owner of this car. Non-cascading on purpose: deleting a user must not + // wipe their cars (account deletion in me.go intentionally leaves cars). + // required:false at the DB level — the API always sets owner on create and + // existing rows are backfilled (scripts/backfill-car-owners.mjs). + F.relation("owner", "users", false, false), + ], + service_records: [ + F.relation("car", "cars", true), + F.date("date", true), + F.number("km"), + F.bool("changed_oil"), + F.bool("changed_engine_air_filter"), + F.bool("changed_cabin_air_filter"), + F.text("notes"), + ], + parts: [ + F.relation("car", "cars", true), + F.text("name", true), + F.text("part_number"), + F.text("category"), + ], + // Per-car sharing grants. One row = "this user may access this car" at the + // given permission. Cascades on both relations so grants disappear when + // either the car or the user is deleted. (Owner access is NOT stored here — + // it's implied by cars.owner.) + car_shares: [ + F.relation("car", "cars", true), + F.relation("user", "users", true), + F.select("permission", ["read", "write"], 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: [ + F.text("bio"), + F.select("theme", ["light", "dark", "system"]), + F.text("locale"), + F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]), + 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), + ], +}; + +async function main() { + console.log(`Connecting to ${PB_URL} ...`); + const token = await authenticate(); + console.log("Authenticated as superuser."); + + let collections = await listCollections(token); + const format = detectFormat(collections); + console.log(`Schema format: "${format}"`); + + 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"]) { + if (collections.some((c) => c.name === name)) continue; + await createCollection(token, name, DESIRED[name], format, idByName); + console.log(`✓ ${name} — created`); + // Refresh so later relations can reference newly-created collection ids. + collections = await listCollections(token); + 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"]) { + await reconcileFields(token, name, DESIRED[name], format, idByName); + } + + console.log("\nDone. Collections ready: users, cars, service_records, parts, sessions, car_shares."); +} + +main().catch((err) => { + console.error("\nSetup failed:", err.message); + process.exit(1); +}); diff --git a/Docker AIO/.env.example b/Docker AIO/.env.example new file mode 100644 index 0000000..5a03269 --- /dev/null +++ b/Docker AIO/.env.example @@ -0,0 +1,20 @@ +# Copy to .env and fill in. Used by the Docker AIO docker-compose.yml. + +# --- Required (no defaults) -------------------------------------------------- +# PocketBase superuser, also used by the API Server to authenticate. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password +# Signs auth tokens. Generate e.g.: openssl rand -hex 32 +AUTH_SECRET=change-me-to-a-long-random-value + +# --- Host port mappings (optional; defaults shown) -------------------------- +WEB_PORT=80 +PB_PORT=8090 +# API Server + its embedded web panel (served at the API root, http://host:8080/). +API_PORT=8080 + +# --- Build args (optional) --------------------------------------------------- +# Leave empty so the browser uses same-origin /api (proxied by nginx). +VITE_API_BASE= +# Pin a PocketBase version, or leave empty to fetch the latest at build time. +PB_VERSION= diff --git a/Docker AIO/Dockerfile b/Docker AIO/Dockerfile new file mode 100644 index 0000000..e4e7975 --- /dev/null +++ b/Docker AIO/Dockerfile @@ -0,0 +1,161 @@ +# syntax=docker/dockerfile:1 +# +# All-in-one image: PocketBase + API Server + Web App in a single container. +# +# The build context MUST be the project root so this file can reach both +# "API Server/" and "Web App/". Build it with: +# +# docker build -f "Docker AIO/Dockerfile" -t carcontrol-aio . +# +# Run it (all three services start together): +# +# docker run -d --name carcontrol -p 80:80 -p 8090:8090 \ +# -e PB_ADMIN_EMAIL=admin@example.com \ +# -e PB_ADMIN_PASSWORD=change-me \ +# -e AUTH_SECRET=$(openssl rand -hex 32) \ +# -v carcontrol_pb:/pb/pb_data \ +# carcontrol-aio +# +# Then: web app on http://host/ and PocketBase admin on http://host:8090/_/ + +# --- Stage 1: build the Go API Server --------------------------------------- +FROM golang:1.22-alpine AS api-build +WORKDIR /src +COPY ["API Server/go.mod", "./"] +COPY ["API Server/go.su[m]", "./"] +RUN go mod download +# Only main.go + internal are needed; the panel is already built into +# internal/api/dist and embedded via //go:embed. +COPY ["API Server/main.go", "./"] +COPY ["API Server/internal", "./internal"] +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server . + +# --- Stage 2: build the Vue Web App ----------------------------------------- +FROM node:22-alpine AS web-build +WORKDIR /app +COPY ["Web App/package.json", "Web App/package-lock.json", "./"] +RUN npm ci +COPY ["Web App/index.html", "Web App/vite.config.js", "./"] +COPY ["Web App/src", "./src"] +COPY ["Web App/public", "./public"] +# Empty -> bundle uses same-origin "/api", proxied to the API Server by nginx. +ARG VITE_API_BASE +RUN npm run build + +# --- Stage 3: runtime (all services) ---------------------------------------- +FROM alpine:latest + +ARG PB_VERSION="" +ARG TARGETARCH="amd64" + +RUN apk add --no-cache ca-certificates tzdata unzip wget nginx supervisor \ + && mkdir -p /run/nginx + +# PocketBase from the official release (pinned via PB_VERSION, else latest). +WORKDIR /pb +RUN set -eux; \ + ver="${PB_VERSION}"; \ + if [ -z "$ver" ]; then \ + ver="$(wget -qO- https://api.github.com/repos/pocketbase/pocketbase/releases/latest \ + | grep -o '"tag_name": *"v[^"]*"' | head -1 | sed -E 's/.*"v([^"]+)".*/\1/')"; \ + fi; \ + echo "Installing PocketBase v${ver} (${TARGETARCH})"; \ + wget -q -O /tmp/pb.zip \ + "https://github.com/pocketbase/pocketbase/releases/download/v${ver}/pocketbase_${ver}_linux_${TARGETARCH}.zip"; \ + unzip /tmp/pb.zip -d /pb; \ + rm /tmp/pb.zip + +# API Server binary + built Web App static assets. +COPY --from=api-build /out/api-server /usr/local/bin/api-server +COPY --from=web-build /app/dist /usr/share/nginx/html + +# nginx: serve the SPA and proxy /api/ to the API Server on localhost. +RUN cat > /etc/nginx/http.d/default.conf <<'NGINX' +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + location /api/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +NGINX + +# supervisord runs the three processes and keeps them alive. +RUN cat > /etc/supervisord.conf <<'SUPERVISOR' +[supervisord] +nodaemon=true +user=root +pidfile=/run/supervisord.pid +logfile=/dev/null +logfile_maxbytes=0 + +; PocketBase: upsert the superuser (idempotent) then serve. +[program:pocketbase] +directory=/pb +command=/bin/sh -c '/pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8090' +priority=10 +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +; API Server: wait for PocketBase to be healthy, then start. +[program:api-server] +command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8090/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server' +priority=20 +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=/usr/sbin/nginx -g 'daemon off;' +priority=30 +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +SUPERVISOR + +# API Server config: everything is local to this container. +ENV PORT=8080 \ + PB_URL=http://127.0.0.1:8090 \ + CORS_ORIGINS=http://localhost \ + AUTH_USERS_COLLECTION=users + +# Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, +# AUTH_SECRET. Pass them with `docker run -e ...`. + +VOLUME /pb/pb_data +# 80 = Web App, 8090 = PocketBase admin, 8080 = API Server + embedded API panel. +EXPOSE 80 8090 8080 + +CMD ["supervisord", "-c", "/etc/supervisord.conf"] diff --git a/Docker AIO/docker-compose.yml b/Docker AIO/docker-compose.yml new file mode 100644 index 0000000..989adda --- /dev/null +++ b/Docker AIO/docker-compose.yml @@ -0,0 +1,35 @@ +name: carcontrol-aio + +# Single all-in-one container: PocketBase + API Server + Web App (nginx). +# The build context is the project root so the Dockerfile can reach both +# "API Server/" and "Web App/". Copy .env.example to .env before starting. + +services: + carcontrol: + build: + # Project root (one level up from this compose file). + context: .. + dockerfile: Docker AIO/Dockerfile + args: + # Empty -> bundle uses same-origin "/api", proxied internally by nginx. + VITE_API_BASE: "${VITE_API_BASE:-}" + # Optional: pin PocketBase; empty fetches the latest release at build. + PB_VERSION: "${PB_VERSION:-}" + image: carcontrol-aio + container_name: carcontrol-aio + restart: unless-stopped + environment: + # Superuser (also used by the API Server to authenticate to PocketBase). + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + # Signs auth tokens — set to a long random value. + AUTH_SECRET: "${AUTH_SECRET:?set AUTH_SECRET in .env}" + ports: + - "${WEB_PORT:-80}:80" # Web App + - "${PB_PORT:-8090}:8090" # PocketBase admin UI / API + - "${API_PORT:-8080}:8080" # API Server + embedded API web panel (root /) + volumes: + - pb_data:/pb/pb_data + +volumes: + pb_data: diff --git a/Docker/.env.example b/Docker/.env.example new file mode 100644 index 0000000..ab5733e --- /dev/null +++ b/Docker/.env.example @@ -0,0 +1,22 @@ +# Copy to .env and fill in. Used by the root docker-compose.yml. + +# --- PocketBase superuser (also used by the API Server to authenticate) ------ +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- API Server ------------------------------------------------------------- +# Long random value used to sign auth tokens. Generate e.g.: +# openssl rand -hex 32 +AUTH_SECRET=change-me-to-a-long-random-value +# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL). +CORS_ORIGINS=http://localhost:8081 +AUTH_USERS_COLLECTION=users + +# --- Host port mappings (optional; defaults shown) -------------------------- +PB_PORT=8090 +API_PORT=8080 +WEB_PORT=8081 + +# --- Web App build ----------------------------------------------------------- +# Leave empty so the browser uses same-origin /api (proxied by nginx). +VITE_API_BASE= diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml new file mode 100644 index 0000000..9b89851 --- /dev/null +++ b/Docker/docker-compose.yml @@ -0,0 +1,73 @@ +name: carcontrol + +# Full Car Control / DriverVault stack: PocketBase (database) + API Server + Web App. +# Traffic flow (browser): Web App (nginx) --/api--> API Server --> PocketBase. +# Copy .env.example to .env and fill in the secrets before `docker compose up`. + +services: + pocketbase: + build: + context: ./pocketbase + image: carcontrol-pocketbase + container_name: carcontrol-pocketbase + restart: unless-stopped + environment: + # Superuser is created/updated on boot so the API Server can authenticate. + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + volumes: + - pb_data:/pb/pb_data + ports: + # Admin UI / API exposed on the host for management (http://host:8090/_/). + - "${PB_PORT:-8090}:8090" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/api/health || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + + api-server: + build: + context: ../API Server + image: carcontrol-api + container_name: carcontrol-api + restart: unless-stopped + depends_on: + pocketbase: + condition: service_healthy + environment: + PORT: "8080" + # Reach PocketBase by its service name on the internal network. + PB_URL: "http://pocketbase:8090" + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" + AUTH_SECRET: "${AUTH_SECRET:?set AUTH_SECRET in .env}" + # Same-origin requests go through nginx, so CORS is only needed if the + # browser ever calls the API Server directly. Default to the web origin. + CORS_ORIGINS: "${CORS_ORIGINS:-http://localhost:8081}" + AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" + ports: + # Optional direct access to the API Server; the Web App uses the internal + # network, not this host port. + - "${API_PORT:-8080}:8080" + + web-app: + build: + context: ../Web App + args: + # Empty -> bundle uses same-origin "/api", which nginx proxies below. + VITE_API_BASE: "${VITE_API_BASE:-}" + image: drivervault-web + container_name: drivervault-web + restart: unless-stopped + depends_on: + - api-server + environment: + # nginx proxies /api/ to the API Server over the internal network. + API_TARGET: "http://api-server:8080" + ports: + - "${WEB_PORT:-8081}:80" + +volumes: + pb_data: diff --git a/Docker/pocketbase/Dockerfile b/Docker/pocketbase/Dockerfile new file mode 100644 index 0000000..0c4f416 --- /dev/null +++ b/Docker/pocketbase/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 + +# PocketBase built from the official release binary on alpine:latest. +FROM alpine:latest + +# Pin a version, or leave empty to fetch the latest release at build time. +ARG PB_VERSION="" +# Provided automatically by BuildKit (amd64 / arm64). +ARG TARGETARCH="amd64" + +RUN apk add --no-cache ca-certificates unzip wget + +WORKDIR /pb + +RUN set -eux; \ + ver="${PB_VERSION}"; \ + if [ -z "$ver" ]; then \ + ver="$(wget -qO- https://api.github.com/repos/pocketbase/pocketbase/releases/latest \ + | grep -o '"tag_name": *"v[^"]*"' | head -1 | sed -E 's/.*"v([^"]+)".*/\1/')"; \ + fi; \ + echo "Installing PocketBase v${ver} (${TARGETARCH})"; \ + wget -q -O /tmp/pb.zip \ + "https://github.com/pocketbase/pocketbase/releases/download/v${ver}/pocketbase_${ver}_linux_${TARGETARCH}.zip"; \ + unzip /tmp/pb.zip -d /pb; \ + rm /tmp/pb.zip + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# pb_data holds the SQLite database and uploads — mount a volume here. +VOLUME /pb/pb_data +EXPOSE 8090 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/Docker/pocketbase/entrypoint.sh b/Docker/pocketbase/entrypoint.sh new file mode 100644 index 0000000..38b5fbf --- /dev/null +++ b/Docker/pocketbase/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Create/update the superuser from env vars so the API Server can authenticate +# on first boot. `superuser upsert` is idempotent (PocketBase v0.23+). +if [ -n "$PB_ADMIN_EMAIL" ] && [ -n "$PB_ADMIN_PASSWORD" ]; then + echo "Ensuring PocketBase superuser $PB_ADMIN_EMAIL exists..." + /pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" \ + || echo "warning: superuser upsert failed; create an admin via the UI at /_/" +fi + +exec /pb/pocketbase serve --http=0.0.0.0:8090 diff --git a/Phone App/.flutter-plugins b/Phone App/.flutter-plugins new file mode 100644 index 0000000..5541358 --- /dev/null +++ b/Phone App/.flutter-plugins @@ -0,0 +1,9 @@ +# This is a generated file; do not edit or check into version control. +path_provider_linux=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_linux-2.2.1\\ +path_provider_windows=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\path_provider_windows-2.3.0\\ +shared_preferences=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences-2.5.3\\ +shared_preferences_android=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_android-2.4.11\\ +shared_preferences_foundation=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_foundation-2.5.4\\ +shared_preferences_linux=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_linux-2.4.1\\ +shared_preferences_web=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_web-2.4.3\\ +shared_preferences_windows=C:\\Users\\jania\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\shared_preferences_windows-2.4.1\\ diff --git a/Phone App/.gitignore b/Phone App/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/Phone App/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/Phone App/.metadata b/Phone App/.metadata new file mode 100644 index 0000000..431d2bf --- /dev/null +++ b/Phone App/.metadata @@ -0,0 +1,33 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: web + create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/Phone App/README.md b/Phone App/README.md new file mode 100644 index 0000000..ee7a40b --- /dev/null +++ b/Phone App/README.md @@ -0,0 +1,102 @@ +# Car Control — Phone App (Flutter) + +A Flutter client for the Car Control maintenance tracker. Talks **only** to the +API Server (same contract and JWT auth as the web app). At full feature parity +with the web app (data export/import is the only deliberate omission). + +Project name `carcontrol_phone`, package id `com.carcontrole.carcontrol_phone`. +**Android is the supported target** — the older Flutter-web build path is +deprecated. + +## Features + +- **Login** — email/password against `/api/auth/login`, password show/hide, and a + collapsible **Server settings** section to override the API base URL on-device. +- **Biometric / face sign-in + app lock** — see the dedicated section below. +- **Dashboard** — car list with next-due status badges (date + km, worst-of), + a "shared" chip on cars owned by someone else, pull-to-refresh, **Add car** + FAB, Settings gear, and an admin action (admins only). +- **Car detail** — all spec fields (incl. VIN and transmission / differential / + brake / coolant specs), tabs for **Service history** and **Parts catalog**, + edit car, add/edit/delete service records and parts, a **share** sheet + (owner only), quick odometer update, and delete car (type-to-confirm; cascades). + Actions are gated by the caller's access level (read-only vs write vs owner). +- **Settings** — account (name / email verification / password), appearance + (theme + dark mode, locale, date format, font size), profile (avatar via + `image_picker`, bio), **Security** (biometric toggle), active sessions with + remote logout, and the account-deletion state machine. +- **Admin** — user management screen (list / create / role / reset password / + delete), gated by the admin role. + +Sharing/ownership: `Car.access` drives `isOwner` / `canWrite` / `isReadOnly` +getters that gate the UI, mirroring the server's access checks. + +## Biometric / face sign-in & app lock + +Fingerprint and face-recognition sign-in via `local_auth`, with credentials kept +in Android Keystore–backed secure storage (`flutter_secure_storage`). + +- After a successful password login the app offers to **enable biometric login**; + the entered (known-good) credentials are stored securely. +- The login screen then shows **"Sign in with face recognition / fingerprint"** + buttons (labels reflect the enrolled biometric kinds) and auto-prompts once. + On success the stored credentials are replayed against the normal login API, so + each biometric sign-in mints a fresh session. Stale credentials (e.g. after a + password change) auto-disable biometric login. +- **App lock** — the JWT persists, so a valid session normally restores silently. + When biometric login is enabled the app instead starts **locked** (and re-locks + when backgrounded) and shows a lock screen requiring a biometric unlock. A + **30-second grace period** means quick app-switches don't re-lock; a full app + close (process kill) always locks on next launch. "Use password instead" on the + lock screen logs out and returns to the login form. +- Manage it under **Settings → Security** (enabling re-confirms the password). + +Android host requirements (already configured, don't revert): +`MainActivity` extends **`FlutterFragmentActivity`** (required by `local_auth`), +and `AndroidManifest.xml` declares `android.permission.USE_BIOMETRIC`. + +## Configure the API endpoint + +The app talks to `kDefaultApiBase` (see `lib/config.dart`), default +`http://localhost:8080/api`. Override at build time with `--dart-define`, or at +runtime from the login screen's **Server settings** (persisted as `cc_server_url`). + +## Run & build + +```bash +flutter pub get + +# run on a connected device against a LAN server +flutter run -d --dart-define=API_BASE=http://10.2.1.101:8080/api + +# build a debug APK for a real phone on the LAN +flutter build apk --debug --dart-define=API_BASE=http://10.2.1.101:8080/api +adb install -r build/app/outputs/flutter-apk/app-debug.apk +adb shell monkey -p com.carcontrole.carcontrol_phone -c android.intent.category.LAUNCHER 1 +``` + +Notes: +- `android/gradle.properties` sets `kotlin.incremental=false` — required because + the project lives on drive `E:` while Gradle/Kotlin caches are on `C:` (the + incremental compiler can't compute cross-root relative paths on Windows). +- `AndroidManifest.xml` sets `android:usesCleartextTraffic="true"` because the API + base is a plain-HTTP LAN URL. +- The API Server must be running and reachable at the configured URL. + +## Structure + +``` +lib/ +├── config.dart # default API base URL (kDefaultApiBase) +├── models.dart # Car (+ access getters), ServiceRecord, Part, AuthUser, UserProfile, Session +├── api.dart # ApiClient — the only thing that calls the API Server +├── auth.dart # AuthService (token persistence, app-lock flag, ChangeNotifier) +├── biometric.dart # BiometricAuth — local_auth + secure storage; biometricAuth singleton +├── app_settings.dart # AppSettings (theme/locale/date/font), persisted; drives MaterialApp +├── format.dart # date/km formatting + next-service status (worst-of date/km) +├── main.dart # app root; routes Login / Lock / Dashboard; lifecycle-based re-lock +└── screens/ + ├── login_screen.dart dashboard_screen.dart car_detail_screen.dart + ├── car_form_sheet.dart settings_screen.dart admin_users_screen.dart + └── lock_screen.dart +``` diff --git a/Phone App/analysis_options.yaml b/Phone App/analysis_options.yaml new file mode 100644 index 0000000..7c410f9 --- /dev/null +++ b/Phone App/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true diff --git a/Phone App/android/.gitignore b/Phone App/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/Phone App/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/Phone App/android/app/build.gradle.kts b/Phone App/android/app/build.gradle.kts new file mode 100644 index 0000000..174a5c3 --- /dev/null +++ b/Phone App/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.carcontrole.carcontrol_phone" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.carcontrole.carcontrol_phone" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/Phone App/android/app/src/debug/AndroidManifest.xml b/Phone App/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/Phone App/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/Phone App/android/app/src/main/AndroidManifest.xml b/Phone App/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..745c0df --- /dev/null +++ b/Phone App/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Phone App/android/app/src/main/kotlin/com/carcontrole/carcontrol_phone/MainActivity.kt b/Phone App/android/app/src/main/kotlin/com/carcontrole/carcontrol_phone/MainActivity.kt new file mode 100644 index 0000000..cf26b6f --- /dev/null +++ b/Phone App/android/app/src/main/kotlin/com/carcontrole/carcontrol_phone/MainActivity.kt @@ -0,0 +1,7 @@ +package com.carcontrole.carcontrol_phone + +import io.flutter.embedding.android.FlutterFragmentActivity + +// local_auth requires a FragmentActivity host (not the default FlutterActivity) +// so the system BiometricPrompt can attach to the activity's fragment manager. +class MainActivity : FlutterFragmentActivity() diff --git a/Phone App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..25fcc73 Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..240ce7c Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable-v21/launch_background.xml b/Phone App/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/Phone App/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/Phone App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f94ee45 Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a539835 Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/Phone App/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6afcad4 Binary files /dev/null and b/Phone App/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/Phone App/android/app/src/main/res/drawable/launch_background.xml b/Phone App/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/Phone App/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/Phone App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Phone App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/Phone App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/Phone App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..45605eb Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..a42b2c8 Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..ad80f50 Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..9f4d8f5 Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Phone App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..db8be2e Binary files /dev/null and b/Phone App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/Phone App/android/app/src/main/res/values-night/styles.xml b/Phone App/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/Phone App/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/Phone App/android/app/src/main/res/values/colors.xml b/Phone App/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..573b1b1 --- /dev/null +++ b/Phone App/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #1E40AF + \ No newline at end of file diff --git a/Phone App/android/app/src/main/res/values/styles.xml b/Phone App/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/Phone App/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/Phone App/android/app/src/profile/AndroidManifest.xml b/Phone App/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/Phone App/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/Phone App/android/build.gradle.kts b/Phone App/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/Phone App/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/Phone App/android/gradle.properties b/Phone App/android/gradle.properties new file mode 100644 index 0000000..8c8289b --- /dev/null +++ b/Phone App/android/gradle.properties @@ -0,0 +1,10 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false +# Kotlin's incremental compiler computes relative paths between the project +# (drive E:) and the Gradle/Kotlin caches (drive C:), which throws +# "this and base files have different roots" on Windows. Disable it. +kotlin.incremental=false diff --git a/Phone App/android/gradle/wrapper/gradle-wrapper.properties b/Phone App/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/Phone App/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/Phone App/android/settings.gradle.kts b/Phone App/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/Phone App/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/Phone App/assets/icon/drivervault-fg.png b/Phone App/assets/icon/drivervault-fg.png new file mode 100644 index 0000000..3ec14ac Binary files /dev/null and b/Phone App/assets/icon/drivervault-fg.png differ diff --git a/Phone App/assets/icon/drivervault.png b/Phone App/assets/icon/drivervault.png new file mode 100644 index 0000000..8282f80 Binary files /dev/null and b/Phone App/assets/icon/drivervault.png differ diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart new file mode 100644 index 0000000..551e072 --- /dev/null +++ b/Phone App/lib/api.dart @@ -0,0 +1,254 @@ +import "dart:convert"; +import "package:http/http.dart" as http; +import "package:shared_preferences/shared_preferences.dart"; + +import "config.dart"; +import "models.dart"; + +/// Thrown when the API Server returns a non-2xx response. +class ApiException implements Exception { + final int status; + final String message; + ApiException(this.status, this.message); + @override + String toString() => message; +} + +/// The single client for the Car Control API Server. Holds the bearer token and +/// attaches it to every request. On 401 it calls [onUnauthorized] so the app can +/// route back to login. +class ApiClient { + String? token; + void Function()? onUnauthorized; + + static const _serverKey = "cc_server_url"; + + /// The effective API base URL. Defaults to [kDefaultApiBase]; a saved override + /// (login screen "Server settings") replaces it via [loadServerUrl]. + String baseUrl = kDefaultApiBase; + + /// Loads a saved server-URL override, if any. Call before the first request. + Future loadServerUrl() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_serverKey); + if (saved != null && saved.isNotEmpty) baseUrl = saved; + } + + /// The current override URL, or "" when using the default. + Future serverOverride() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_serverKey) ?? ""; + } + + /// Persists a server-URL override. Blank clears it (reverts to the default). + /// Trailing slashes are trimmed. + Future setServerUrl(String url) async { + final prefs = await SharedPreferences.getInstance(); + final trimmed = url.trim().replaceAll(RegExp(r"/+$"), ""); + if (trimmed.isEmpty) { + await prefs.remove(_serverKey); + baseUrl = kDefaultApiBase; + } else { + await prefs.setString(_serverKey, trimmed); + baseUrl = trimmed; + } + } + + Map get _headers => { + "Content-Type": "application/json", + if (token != null) "Authorization": "Bearer $token", + }; + + Uri _uri(String path) => Uri.parse("$baseUrl$path"); + + Future _send(String method, String path, {Object? body}) async { + final req = http.Request(method, _uri(path))..headers.addAll(_headers); + if (body != null) req.body = jsonEncode(body); + final streamed = await http.Client().send(req); + final res = await http.Response.fromStream(streamed); + + if (res.statusCode == 401 && path != "/auth/login") { + onUnauthorized?.call(); + throw ApiException(401, "Session expired — please log in again."); + } + if (res.statusCode == 204 || res.body.isEmpty) return null; + + 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"); + } + return data; + } + + // --- auth --- + 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"]))); + } + + // --- cars --- + Future> listCars() async { + final data = await _send("GET", "/cars") as List; + return data.map((e) => Car.fromJson(Map.from(e))).toList(); + } + + Future getCar(String id) async { + final data = await _send("GET", "/cars/$id"); + return Car.fromJson(Map.from(data)); + } + + Future createCar(Map body) async { + final data = await _send("POST", "/cars", body: body); + return Car.fromJson(Map.from(data)); + } + + Future updateCar(String id, Map body) async { + final data = await _send("PATCH", "/cars/$id", body: body); + return Car.fromJson(Map.from(data)); + } + + Future deleteCar(String id) => _send("DELETE", "/cars/$id"); + + // --- sharing (owner-only) --- + Future> listCarShares(String carId) async { + final data = await _send("GET", "/cars/$carId/shares") as List; + return data.map((e) => CarShare.fromJson(Map.from(e))).toList(); + } + + Future addCarShare(String carId, String email, String permission) async { + final data = await _send("POST", "/cars/$carId/shares", + body: {"email": email, "permission": permission}); + return CarShare.fromJson(Map.from(data)); + } + + Future removeCarShare(String carId, String userId) => + _send("DELETE", "/cars/$carId/shares/$userId"); + + // --- admin: user management (admin role only) --- + Future> listUsers() async { + final data = await _send("GET", "/admin/users") as List; + return data.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", + body: {"email": email, "password": password, "name": name ?? "", "role": role}); + return AdminUser.fromJson(Map.from(data)); + } + + 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)); + } + + Future setUserPassword(String id, String newPassword) => + _send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword}); + + Future deleteUser(String id) => _send("DELETE", "/admin/users/$id"); + + // --- service records --- + Future> listCarServices(String carId) async { + final data = await _send("GET", "/cars/$carId/service-records") as List; + return data.map((e) => ServiceRecord.fromJson(Map.from(e))).toList(); + } + + Future createService(Map body) async { + final data = await _send("POST", "/service-records", body: body); + return ServiceRecord.fromJson(Map.from(data)); + } + + Future updateService(String id, Map body) async { + final data = await _send("PATCH", "/service-records/$id", body: body); + return ServiceRecord.fromJson(Map.from(data)); + } + + Future deleteService(String id) => _send("DELETE", "/service-records/$id"); + + // --- parts --- + Future> listCarParts(String carId) async { + final data = await _send("GET", "/cars/$carId/parts") as List; + return data.map((e) => Part.fromJson(Map.from(e))).toList(); + } + + Future createPart(Map body) async { + final data = await _send("POST", "/parts", body: body); + return Part.fromJson(Map.from(data)); + } + + Future updatePart(String id, Map body) async { + final data = await _send("PATCH", "/parts/$id", body: body); + return Part.fromJson(Map.from(data)); + } + + Future deletePart(String id) => _send("DELETE", "/parts/$id"); + + // --- settings: profile / account --- + Future getMe() async { + final data = await _send("GET", "/me"); + return UserProfile.fromJson(Map.from(data)); + } + + Future updateMe(Map patch) async { + final data = await _send("PATCH", "/me", body: patch); + return UserProfile.fromJson(Map.from(data)); + } + + Future changePassword(String oldPassword, String newPassword) => _send( + "POST", + "/me/password", + body: {"oldPassword": oldPassword, "newPassword": newPassword}, + ); + + Future requestVerification() => _send("POST", "/me/verify/request"); + + // --- settings: avatar --- + Future uploadAvatar(List bytes, String filename) async { + final req = http.MultipartRequest("POST", _uri("/me/avatar")); + if (token != null) req.headers["Authorization"] = "Bearer $token"; + req.files.add(http.MultipartFile.fromBytes("avatar", bytes, filename: filename)); + final res = await http.Response.fromStream(await req.send()); + if (res.statusCode == 401) { + onUnauthorized?.call(); + throw ApiException(401, "Session expired — please log in again."); + } + 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 ?? "Upload failed"); + } + return UserProfile.fromJson(Map.from(data)); + } + + Future?> getAvatarBytes() async { + final res = await http.get(_uri("/me/avatar"), + headers: {if (token != null) "Authorization": "Bearer $token"}); + if (res.statusCode == 200) return res.bodyBytes; + return null; + } + + 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}); + final at = (data is Map) ? data["eligibleAt"] : null; + return at == null ? null : DateTime.tryParse(at.toString()); + } + + Future cancelAccountDeletion() => _send("POST", "/me/delete/cancel"); + Future finalizeAccountDeletion() => _send("DELETE", "/me"); +} diff --git a/Phone App/lib/app_settings.dart b/Phone App/lib/app_settings.dart new file mode 100644 index 0000000..1721bb9 --- /dev/null +++ b/Phone App/lib/app_settings.dart @@ -0,0 +1,70 @@ +import "package:flutter/material.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +import "models.dart"; + +/// App-wide appearance preferences (theme / locale / date format / font size), +/// mirroring the web app's prefs.js. Persisted to SharedPreferences so the +/// chosen theme survives a restart before /api/me loads, and exposed as a +/// ChangeNotifier so MaterialApp and date formatting react to changes. +class AppSettings extends ChangeNotifier { + static const _kTheme = "cc_theme"; + static const _kLocale = "cc_locale"; + static const _kDateFormat = "cc_dateFormat"; + static const _kFontSize = "cc_fontSize"; + + String theme = "system"; // light | dark | system + String locale = "en-US"; + String dateFormat = "YMD"; // YMD | DMY_NUM | DMY | MDY + String fontSize = "medium"; // small | medium | large + + Future loadFromStorage() async { + final prefs = await SharedPreferences.getInstance(); + theme = prefs.getString(_kTheme) ?? theme; + locale = prefs.getString(_kLocale) ?? locale; + dateFormat = prefs.getString(_kDateFormat) ?? dateFormat; + fontSize = prefs.getString(_kFontSize) ?? fontSize; + notifyListeners(); + } + + Future _persist() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kTheme, theme); + await prefs.setString(_kLocale, locale); + await prefs.setString(_kDateFormat, dateFormat); + await prefs.setString(_kFontSize, fontSize); + } + + /// Sync from a freshly fetched profile (login, boot, settings saves). + void applyFromProfile(UserProfile p) { + theme = p.theme; + locale = p.locale; + dateFormat = p.dateFormat; + fontSize = p.fontSize; + _persist(); + notifyListeners(); + } + + /// Optimistically apply a single changed field (used by Settings so the UI + /// reacts instantly while the PATCH is in flight). + void patch({String? theme, String? locale, String? dateFormat, String? fontSize}) { + if (theme != null) this.theme = theme; + if (locale != null) this.locale = locale; + if (dateFormat != null) this.dateFormat = dateFormat; + if (fontSize != null) this.fontSize = fontSize; + _persist(); + notifyListeners(); + } + + ThemeMode get themeMode => switch (theme) { + "light" => ThemeMode.light, + "dark" => ThemeMode.dark, + _ => ThemeMode.system, + }; + + double get textScale => switch (fontSize) { + "small" => 0.9375, + "large" => 1.125, + _ => 1.0, + }; +} diff --git a/Phone App/lib/auth.dart b/Phone App/lib/auth.dart new file mode 100644 index 0000000..3424ae3 --- /dev/null +++ b/Phone App/lib/auth.dart @@ -0,0 +1,74 @@ +import "dart:convert"; +import "package:flutter/foundation.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +import "api.dart"; +import "models.dart"; + +const _tokenKey = "cc_token"; +const _userKey = "cc_user"; + +/// Holds session state and persists the token across app restarts. +class AuthService extends ChangeNotifier { + final ApiClient api; + AuthUser? user; + bool ready = false; + + /// In-memory (never persisted) app-lock flag. When biometric login is enabled + /// the app starts/returns locked: the token is still valid but the UI hides + /// behind a biometric unlock instead of jumping straight to the dashboard. + bool locked = false; + + AuthService(this.api) { + api.onUnauthorized = () => logout(); + } + + bool get isAuthenticated => api.token != null; + + void lock() { + if (!locked && isAuthenticated) { + locked = true; + notifyListeners(); + } + } + + void unlock() { + if (locked) { + locked = false; + notifyListeners(); + } + } + + Future loadFromStorage() async { + final prefs = await SharedPreferences.getInstance(); + final t = prefs.getString(_tokenKey); + final u = prefs.getString(_userKey); + if (t != null) { + api.token = t; + if (u != null) user = AuthUser.fromJson(jsonDecode(u)); + } + ready = true; + notifyListeners(); + } + + Future login(String email, String password) async { + final (token, u) = await api.login(email, password); + api.token = token; + user = u; + locked = false; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); + await prefs.setString(_userKey, jsonEncode(u.toJson())); + notifyListeners(); + } + + Future logout() async { + api.token = null; + user = null; + locked = false; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_tokenKey); + await prefs.remove(_userKey); + notifyListeners(); + } +} diff --git a/Phone App/lib/biometric.dart b/Phone App/lib/biometric.dart new file mode 100644 index 0000000..aaffa47 --- /dev/null +++ b/Phone App/lib/biometric.dart @@ -0,0 +1,114 @@ +import "package:flutter/services.dart"; +import "package:flutter_secure_storage/flutter_secure_storage.dart"; +import "package:local_auth/local_auth.dart"; +import "package:local_auth_android/local_auth_android.dart"; + +/// Biometric ("Face recognition" / "Fingerprint") sign-in. +/// +/// The app already persists a JWT in SharedPreferences, so a valid session +/// auto-restores on boot and the login screen is only shown after logout or +/// token expiry. Biometric login covers that case: the user's credentials are +/// kept in Android Keystore-backed secure storage and released only after the +/// system BiometricPrompt succeeds, then replayed against the normal login API +/// (so each biometric sign-in mints a fresh session/token). +class BiometricAuth { + final LocalAuthentication _auth = LocalAuthentication(); + final FlutterSecureStorage _store = const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + + static const _emailKey = "cc_bio_email"; + static const _passwordKey = "cc_bio_password"; + + /// Synchronous mirror of [isEnabled], kept fresh by the async calls below so + /// the app-lifecycle handler (which can't await) can decide whether to lock. + bool enabledCached = false; + + /// Whether the device has usable biometric hardware with something enrolled. + Future isAvailable() async { + try { + if (!await _auth.isDeviceSupported()) return false; + return await _auth.canCheckBiometrics; + } on PlatformException { + return false; + } + } + + /// Which biometric kinds are enrolled (used to label the sign-in buttons). + Future> availableTypes() async { + try { + return await _auth.getAvailableBiometrics(); + } on PlatformException { + return const []; + } + } + + Future hasFace() async => + (await availableTypes()).contains(BiometricType.face); + + Future hasFingerprint() async => + (await availableTypes()).contains(BiometricType.fingerprint); + + /// The email biometric login was enabled for, or null if it's off. + Future enabledEmail() async { + final v = await _store.read(key: _emailKey); + enabledCached = v != null; + return v; + } + + Future isEnabled() async => (await enabledEmail()) != null; + + /// Remember credentials for biometric sign-in. Caller should only invoke this + /// after a successful password login so the stored creds are known-good. + Future enable(String email, String password) async { + await _store.write(key: _emailKey, value: email); + await _store.write(key: _passwordKey, value: password); + enabledCached = true; + } + + Future disable() async { + await _store.delete(key: _emailKey); + await _store.delete(key: _passwordKey); + enabledCached = false; + } + + /// Prompt for biometrics and, on success, return the stored (email, password). + /// Returns null if the user cancels or nothing is stored; throws + /// [BiometricException] with a friendly message on a hard error. + Future<(String, String)?> unlock({String? reason}) async { + bool ok; + try { + ok = await _auth.authenticate( + localizedReason: reason ?? "Sign in to Car Control", + options: const AuthenticationOptions( + biometricOnly: true, + stickyAuth: true, + ), + authMessages: const [ + AndroidAuthMessages( + signInTitle: "Car Control sign-in", + biometricHint: "", + cancelButton: "Use password", + ), + ], + ); + } on PlatformException catch (e) { + throw BiometricException(e.message ?? "Biometric authentication failed."); + } + if (!ok) return null; + final email = await _store.read(key: _emailKey); + final password = await _store.read(key: _passwordKey); + if (email == null || password == null) return null; + return (email, password); + } +} + +class BiometricException implements Exception { + final String message; + BiometricException(this.message); + @override + String toString() => message; +} + +/// App-wide singleton (mirrors the other services in main.dart). +final biometricAuth = BiometricAuth(); diff --git a/Phone App/lib/config.dart b/Phone App/lib/config.dart new file mode 100644 index 0000000..c9f0b00 --- /dev/null +++ b/Phone App/lib/config.dart @@ -0,0 +1,14 @@ +// Default base URL of the Car Control API Server. The phone app talks ONLY to +// the API Server (never to PocketBase directly). +// +// This is only the DEFAULT — the user can override it at runtime from the login +// screen's "Server settings" (persisted to SharedPreferences). The compile-time +// value here is used when there's no saved override. +// +// - Flutter web build on this PC: http://localhost:8080/api works. +// - Real phone on the LAN: set it in Server settings (or at build time via +// flutter run --dart-define=API_BASE=http://10.2.1.10:8080/api) +const String kDefaultApiBase = String.fromEnvironment( + "API_BASE", + defaultValue: "http://localhost:8080/api", +); diff --git a/Phone App/lib/format.dart b/Phone App/lib/format.dart new file mode 100644 index 0000000..e478c46 --- /dev/null +++ b/Phone App/lib/format.dart @@ -0,0 +1,85 @@ +import "package:flutter/material.dart"; +import "package:intl/intl.dart"; + +import "main.dart"; +import "models.dart"; +import "theme.dart"; + +final _numFmt = NumberFormat.decimalPattern(); + +/// Formats a date per the signed-in user's chosen date format (appSettings), +/// mirroring the web app's format.js. +String formatDate(DateTime? d) { + if (d == null) return "—"; + final pattern = switch (appSettings.dateFormat) { + "DMY_NUM" => "dd-MM-yyyy", + "DMY" => "dd MMM yyyy", + "MDY" => "MMM dd, yyyy", + _ => "yyyy-MM-dd", // YMD + }; + return DateFormat(pattern).format(d); +} + +String formatKm(int? km) => (km == null || km == 0) ? "—" : "${_numFmt.format(km)} km"; + +const int _kmSoon = 1000; + +enum StatusKey { unknown, ok, soon, overdue } + +class Status { + final StatusKey key; + final String label; + const Status(this.key, this.label); + + /// Soft tint background for the status pill — DriverVault semantic colours, + /// re-cut for dark surfaces. + Color bg(bool dark) => switch (key) { + StatusKey.overdue => dark ? DriverVault.dangerSoftDark : DriverVault.dangerSoft, + StatusKey.soon => dark ? DriverVault.warningSoftDark : DriverVault.warningSoft, + StatusKey.ok => dark ? DriverVault.successSoftDark : DriverVault.successSoft, + StatusKey.unknown => dark ? DriverVault.darkSunken : DriverVault.ink50, + }; + Color fg(bool dark) => switch (key) { + StatusKey.overdue => DriverVault.danger, + StatusKey.soon => DriverVault.warning, + StatusKey.ok => DriverVault.success, + StatusKey.unknown => dark ? DriverVault.darkTextMuted : DriverVault.ink500, + }; +} + +int _rank(StatusKey k) => switch (k) { + StatusKey.unknown => 0, + StatusKey.ok => 1, + StatusKey.soon => 2, + StatusKey.overdue => 3, + }; + +Status _dateSignal(DateTime? nextDate) { + if (nextDate == null) return const Status(StatusKey.unknown, "No data"); + final today = DateTime.now(); + final days = DateTime(nextDate.year, nextDate.month, nextDate.day) + .difference(DateTime(today.year, today.month, today.day)) + .inDays; + if (days < 0) return Status(StatusKey.overdue, "Service Overdue ${days.abs()}d"); + if (days <= 30) return Status(StatusKey.soon, "Due in ${days}d"); + return Status(StatusKey.ok, "OK · ${days}d"); +} + +Status _kmSignal(int currentKm, int? nextKm) { + if (currentKm == 0 || nextKm == null) return const Status(StatusKey.unknown, "No km"); + final remaining = nextKm - currentKm; + if (remaining < 0) return Status(StatusKey.overdue, "Service Overdue ${_numFmt.format(remaining.abs())} km"); + if (remaining <= _kmSoon) return Status(StatusKey.soon, "In ${_numFmt.format(remaining)} km"); + return Status(StatusKey.ok, "${_numFmt.format(remaining)} km left"); +} + +/// Combines the date- and km-based signals, returning the worse of the two — +/// the same logic as the web app and the spreadsheet idea. +Status serviceStatus(ServiceRecord? latest, Car car) { + final date = _dateSignal(latest?.nextServiceDate); + final km = _kmSignal(car.currentKm, latest?.nextServiceKm); + final worse = _rank(km.key) > _rank(date.key) ? km : date; + if (date.key == StatusKey.unknown && km.key != StatusKey.unknown) return km; + if (km.key == StatusKey.unknown && date.key != StatusKey.unknown) return date; + return worse; +} diff --git a/Phone App/lib/main.dart b/Phone App/lib/main.dart new file mode 100644 index 0000000..7a4a80e --- /dev/null +++ b/Phone App/lib/main.dart @@ -0,0 +1,109 @@ +import "package:flutter/material.dart"; + +import "api.dart"; +import "app_settings.dart"; +import "auth.dart"; +import "biometric.dart"; +import "theme.dart"; +import "screens/lock_screen.dart"; +import "screens/login_screen.dart"; +import "screens/root_shell.dart"; + +// Simple app-wide singletons (small app — no DI framework needed). +final apiClient = ApiClient(); +final authService = AuthService(apiClient); +final appSettings = AppSettings(); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await apiClient.loadServerUrl(); + await appSettings.loadFromStorage(); + await authService.loadFromStorage(); + // Prime the biometric-enabled cache; if a session survived from a previous + // run and biometric login is on, start locked so the app requires an unlock + // instead of jumping straight to the dashboard. + final bioEnabled = await biometricAuth.isEnabled(); + if (authService.isAuthenticated && bioEnabled) { + authService.lock(); + } + // If a token survived from a previous run, refresh the profile so the saved + // appearance prefs (theme/font/date format) apply on boot. + if (authService.isAuthenticated) { + apiClient.getMe().then((p) => appSettings.applyFromProfile(p)).catchError((_) {}); + } + runApp(const CarControlApp()); +} + +class CarControlApp extends StatefulWidget { + const CarControlApp({super.key}); + @override + State createState() => _CarControlAppState(); +} + +class _CarControlAppState extends State with WidgetsBindingObserver { + // Grace period: a quick app-switch (returning within this window) does NOT + // re-lock; staying in the background longer does. + static const _lockGrace = Duration(seconds: 30); + DateTime? _backgroundedAt; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Re-lock when the app returns from the background, but only if it was away + // longer than the grace period — so quick app-switches don't re-lock. + // Handle only 'paused'/'resumed' (not 'inactive', which also fires + // transiently while the OS biometric prompt is showing). + if (state == AppLifecycleState.paused) { + _backgroundedAt = DateTime.now(); + } else if (state == AppLifecycleState.resumed) { + final since = _backgroundedAt; + _backgroundedAt = null; + if (since != null && + DateTime.now().difference(since) > _lockGrace && + authService.isAuthenticated && + biometricAuth.enabledCached) { + authService.lock(); + } + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: appSettings, + builder: (context, _) => MaterialApp( + title: "DriverVault", + debugShowCheckedModeBanner: false, + theme: DriverVault.theme(Brightness.light), + darkTheme: DriverVault.theme(Brightness.dark), + themeMode: appSettings.themeMode, + // Apply the user's font-size preference app-wide. + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(appSettings.textScale), + ), + child: child!, + ), + home: ListenableBuilder( + listenable: authService, + builder: (context, _) { + if (!authService.isAuthenticated) return const LoginScreen(); + if (authService.locked) return const LockScreen(); + return const RootShell(); + }, + ), + ), + ); + } +} diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart new file mode 100644 index 0000000..74bd197 --- /dev/null +++ b/Phone App/lib/models.dart @@ -0,0 +1,300 @@ +// Domain models mirroring the API Server JSON (which mirrors Car Service.xlsx). + +int _asInt(dynamic v) => v is int ? v : (v is num ? v.toInt() : 0); +String _asStr(dynamic v) => v == null ? "" : v.toString(); +bool _asBool(dynamic v) => v == true; + +class Car { + final String id; + final String name; + final String make; + final String model; + final int year; + final String registration; + final String registrationCountry; + final String vin; + final String oilSpec; + final String transmissionOilSpec; + final String differentialOilSpec; + final String brakeFluidSpec; + final String coolantSpec; + final String fuelType; // petrol | diesel | hybrid | electric + final String buildDate; // ISO YYYY-MM-DD (date-only) + final String firstRegistrationDate; // ISO YYYY-MM-DD (date-only) + final int serviceIntervalDays; + final int serviceIntervalKm; + final int currentKm; + + /// The requesting user's permission on this car: "owner", "write", or "read". + /// The API sets it on every car read. Defaults to "owner" so older responses + /// (or any code that constructs a Car without it) stay fully editable. + final String access; + + Car({ + required this.id, + required this.name, + required this.make, + required this.model, + required this.year, + required this.registration, + this.registrationCountry = "", + required this.vin, + required this.oilSpec, + required this.transmissionOilSpec, + required this.differentialOilSpec, + required this.brakeFluidSpec, + required this.coolantSpec, + this.fuelType = "", + this.buildDate = "", + this.firstRegistrationDate = "", + required this.serviceIntervalDays, + required this.serviceIntervalKm, + required this.currentKm, + this.access = "owner", + }); + + factory Car.fromJson(Map j) => Car( + id: _asStr(j["id"]), + name: _asStr(j["name"]), + make: _asStr(j["make"]), + model: _asStr(j["model"]), + year: _asInt(j["year"]), + registration: _asStr(j["registration"]), + registrationCountry: _asStr(j["registrationCountry"]), + vin: _asStr(j["vin"]), + oilSpec: _asStr(j["oilSpec"]), + transmissionOilSpec: _asStr(j["transmissionOilSpec"]), + differentialOilSpec: _asStr(j["differentialOilSpec"]), + brakeFluidSpec: _asStr(j["brakeFluidSpec"]), + coolantSpec: _asStr(j["coolantSpec"]), + fuelType: _asStr(j["fuelType"]), + buildDate: _asStr(j["buildDate"]), + firstRegistrationDate: _asStr(j["firstRegistrationDate"]), + serviceIntervalDays: _asInt(j["serviceIntervalDays"]), + serviceIntervalKm: _asInt(j["serviceIntervalKm"]), + currentKm: _asInt(j["currentKm"]), + access: j["access"] == null ? "owner" : _asStr(j["access"]), + ); + + bool get isOwner => access == "owner"; + bool get canWrite => access == "owner" || access == "write"; + bool get isReadOnly => access == "read"; + + String get subtitle => + [make, model, year > 0 ? "$year" : ""].where((s) => s.isNotEmpty).join(" "); +} + +class ServiceRecord { + final String id; + final String car; + final DateTime? date; + final int km; + final bool changedOil; + final bool changedEngineAirFilter; + final bool changedCabinAirFilter; + final String notes; + final DateTime? nextServiceDate; + final int? nextServiceKm; + + ServiceRecord({ + required this.id, + required this.car, + required this.date, + required this.km, + required this.changedOil, + required this.changedEngineAirFilter, + required this.changedCabinAirFilter, + required this.notes, + required this.nextServiceDate, + required this.nextServiceKm, + }); + + factory ServiceRecord.fromJson(Map j) => ServiceRecord( + id: _asStr(j["id"]), + car: _asStr(j["car"]), + date: DateTime.tryParse(_asStr(j["date"]))?.toLocal(), + km: _asInt(j["km"]), + changedOil: _asBool(j["changedOil"]), + changedEngineAirFilter: _asBool(j["changedEngineAirFilter"]), + changedCabinAirFilter: _asBool(j["changedCabinAirFilter"]), + notes: _asStr(j["notes"]), + nextServiceDate: j["nextServiceDate"] != null + ? DateTime.tryParse(_asStr(j["nextServiceDate"]))?.toLocal() + : null, + nextServiceKm: j["nextServiceKm"] == null ? null : _asInt(j["nextServiceKm"]), + ); +} + +class Part { + final String id; + final String car; + final String name; + final String partNumber; + final String category; + + Part({ + required this.id, + required this.car, + required this.name, + required this.partNumber, + this.category = "", + }); + + factory Part.fromJson(Map j) => Part( + id: _asStr(j["id"]), + car: _asStr(j["car"]), + name: _asStr(j["name"]), + partNumber: _asStr(j["partNumber"]), + category: _asStr(j["category"]), + ); +} + +/// A sharing grant: another user's access to one of your cars. +class CarShare { + final String userId; + final String email; + final String name; + final String permission; // "read" | "write" + + CarShare({ + required this.userId, + required this.email, + required this.name, + required this.permission, + }); + + factory CarShare.fromJson(Map j) { + final user = Map.from(j["user"] ?? {}); + return CarShare( + userId: _asStr(user["id"]), + email: _asStr(user["email"]), + name: _asStr(user["name"]), + permission: _asStr(j["permission"]), + ); + } + + String get label => name.isNotEmpty ? name : email; +} + +class AuthUser { + final String id; + final String email; + final String name; + final String role; // "user" | "admin" + 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"]), + ); + + bool get isAdmin => role == "admin"; + + Map toJson() => {"id": id, "email": email, "name": name, "role": role}; +} + +/// A user record as returned by the admin user-management endpoints. +class AdminUser { + final String id; + final String email; + final String name; + final String role; + final String created; + + AdminUser({ + required this.id, + required this.email, + required this.name, + required this.role, + required this.created, + }); + + factory AdminUser.fromJson(Map j) => AdminUser( + id: _asStr(j["id"]), + email: _asStr(j["email"]), + name: _asStr(j["name"]), + role: _asStr(j["role"]), + created: _asStr(j["created"]), + ); +} + +/// The full authenticated profile (Settings panel), mirroring /api/me. +class UserProfile { + final String id; + final String email; + final bool verified; + final String name; + final String bio; + final bool hasAvatar; + final String theme; // light | dark | system + final String locale; + final String dateFormat; // YMD | DMY_NUM | DMY | MDY + final String fontSize; // small | medium | large + final String role; // user | admin + final DateTime? deletionRequestedAt; + + UserProfile({ + required this.id, + required this.email, + required this.verified, + required this.name, + required this.bio, + required this.hasAvatar, + required this.theme, + required this.locale, + required this.dateFormat, + required this.fontSize, + required this.role, + required this.deletionRequestedAt, + }); + + factory UserProfile.fromJson(Map j) => UserProfile( + id: _asStr(j["id"]), + email: _asStr(j["email"]), + verified: _asBool(j["verified"]), + name: _asStr(j["name"]), + bio: _asStr(j["bio"]), + hasAvatar: _asBool(j["hasAvatar"]), + theme: j["theme"] == null ? "system" : _asStr(j["theme"]), + locale: j["locale"] == null ? "en-US" : _asStr(j["locale"]), + dateFormat: j["dateFormat"] == null ? "YMD" : _asStr(j["dateFormat"]), + fontSize: j["fontSize"] == null ? "medium" : _asStr(j["fontSize"]), + role: j["role"] == null ? "user" : _asStr(j["role"]), + deletionRequestedAt: j["deletionRequestedAt"] == null + ? null + : DateTime.tryParse(_asStr(j["deletionRequestedAt"]))?.toLocal(), + ); + + bool get isAdmin => role == "admin"; + 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(), + ); +} diff --git a/Phone App/lib/screens/admin_users_screen.dart b/Phone App/lib/screens/admin_users_screen.dart new file mode 100644 index 0000000..85bb506 --- /dev/null +++ b/Phone App/lib/screens/admin_users_screen.dart @@ -0,0 +1,296 @@ +import "package:flutter/material.dart"; + +import "../main.dart"; +import "../models.dart"; +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. +class AdminUsersScreen extends StatefulWidget { + const AdminUsersScreen({super.key}); + @override + State createState() => _AdminUsersScreenState(); +} + +class _AdminUsersScreenState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = apiClient.listUsers(); + } + + void _reload() => setState(() => _future = apiClient.listUsers()); + + String? get _myId => authService.user?.id; + + void _snack(String msg) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + + Future _changeRole(AdminUser u, String role) async { + try { + await apiClient.updateUser(u.id, role: role); + _reload(); + } catch (e) { + _snack("$e"); + } + } + + Future _resetPassword(AdminUser u) async { + final controller = TextEditingController(); + final saved = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text("Reset password — ${u.email}"), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: "New password (min 8)", + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Set password")), + ], + ), + ); + if (saved != true) return; + try { + await apiClient.setUserPassword(u.id, controller.text); + _snack("Password updated."); + } catch (e) { + _snack("$e"); + } + } + + Future _delete(AdminUser u) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Delete user?"), + content: Text("Delete ${u.name.isEmpty ? u.email : u.name}? This cannot be undone."), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), + onPressed: () => Navigator.pop(ctx, true), + child: const Text("Delete"), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await apiClient.deleteUser(u.id); + _reload(); + } catch (e) { + _snack("$e"); + } + } + + Future _createUser() async { + final created = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const _CreateUserSheet(), + ); + if (created == true) _reload(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Users")), + floatingActionButton: FloatingActionButton.extended( + onPressed: _createUser, + icon: const Icon(Icons.person_add_alt_1), + label: const Text("Add user"), + ), + body: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + 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, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final u = users[i]; + final isSelf = u.id == _myId; + final lastAdmin = u.role == "admin" && adminCount <= 1; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + title: Row( + children: [ + Flexible(child: Text(u.email, overflow: TextOverflow.ellipsis)), + if (isSelf) + const Padding( + padding: EdgeInsets.only(left: 6), + child: Text("(you)", style: TextStyle(color: Colors.grey, fontSize: 12)), + ), + ], + ), + subtitle: Text( + "${u.name.isEmpty ? '—' : u.name} · ${formatDate(DateTime.tryParse(u.created))}", + style: const TextStyle(fontSize: 12), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButton( + value: u.role == "admin" ? "admin" : "user", + 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")), + ], + ), + PopupMenuButton( + onSelected: (choice) { + if (choice == "password") _resetPassword(u); + if (choice == "delete") _delete(u); + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: "password", child: Text("Reset password")), + PopupMenuItem( + value: "delete", + // Can't delete yourself or the last admin. + enabled: !isSelf && !lastAdmin, + child: const Text("Delete", style: TextStyle(color: DriverVault.danger)), + ), + ], + ), + ], + ), + ); + }, + ); + }, + ), + ); + } +} + +class _CreateUserSheet extends StatefulWidget { + const _CreateUserSheet(); + @override + State<_CreateUserSheet> createState() => _CreateUserSheetState(); +} + +class _CreateUserSheetState extends State<_CreateUserSheet> { + final _email = TextEditingController(); + final _name = TextEditingController(); + final _password = TextEditingController(); + String _role = "user"; + bool _saving = false; + String? _error; + + @override + void dispose() { + _email.dispose(); + _name.dispose(); + _password.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + try { + await apiClient.createUser( + email: _email.text.trim(), + password: _password.text, + name: _name.text.trim(), + role: _role, + ); + if (mounted) Navigator.pop(context, true); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Add a user", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), + ), + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()), + ), + const SizedBox(height: 8), + TextField( + controller: _name, + decoration: const InputDecoration(labelText: "Name", border: OutlineInputBorder()), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _password, + decoration: const InputDecoration( + labelText: "Password (min 8)", + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + DropdownButton( + value: _role, + onChanged: (v) => setState(() => _role = v ?? "user"), + items: const [ + DropdownMenuItem(value: "user", child: Text("user")), + DropdownMenuItem(value: "admin", child: Text("admin")), + ], + ), + ], + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: Text(_saving ? "Creating…" : "Create user"), + ), + ), + ], + ), + ); + } +} diff --git a/Phone App/lib/screens/car_detail_screen.dart b/Phone App/lib/screens/car_detail_screen.dart new file mode 100644 index 0000000..93f3068 --- /dev/null +++ b/Phone App/lib/screens/car_detail_screen.dart @@ -0,0 +1,1162 @@ +import "package:flutter/material.dart"; + +import "../main.dart"; +import "../models.dart"; +import "../format.dart"; +import "../theme.dart"; +import "car_form_sheet.dart"; + +/// Serializes a car to the full update payload. The API's updateCar rewrites +/// every column from the body, so partial payloads would blank omitted fields — +/// always send them all, overriding only what changed. +Map _carPayload(Car car, {int? currentKm}) => { + "name": car.name, + "make": car.make, + "model": car.model, + "year": car.year, + "registration": car.registration, + "registrationCountry": car.registrationCountry, + "vin": car.vin, + "oilSpec": car.oilSpec, + "transmissionOilSpec": car.transmissionOilSpec, + "differentialOilSpec": car.differentialOilSpec, + "brakeFluidSpec": car.brakeFluidSpec, + "coolantSpec": car.coolantSpec, + "fuelType": car.fuelType, + "buildDate": car.buildDate, + "firstRegistrationDate": car.firstRegistrationDate, + "serviceIntervalDays": car.serviceIntervalDays, + "serviceIntervalKm": car.serviceIntervalKm, + "currentKm": currentKm ?? car.currentKm, + }; + +class CarDetailScreen extends StatefulWidget { + final String carId; + const CarDetailScreen({super.key, required this.carId}); + @override + State createState() => _CarDetailScreenState(); +} + +class _CarDetailData { + final Car car; + final List services; + final List parts; + _CarDetailData(this.car, this.services, this.parts); +} + +class _CarDetailScreenState extends State { + late Future<_CarDetailData> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future<_CarDetailData> _load() async { + final results = await Future.wait([ + apiClient.getCar(widget.carId), + apiClient.listCarServices(widget.carId), + apiClient.listCarParts(widget.carId), + ]); + return _CarDetailData( + results[0] as Car, + results[1] as List, + results[2] as List, + ); + } + + void _reload() => setState(() => _future = _load()); + + Future _addService(Car car) async { + final added = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _ServiceSheet(carId: car.id, car: car), + ); + if (added == true) _reload(); + } + + Future _editService(Car car, ServiceRecord record) async { + final saved = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _ServiceSheet(carId: car.id, car: car, record: record), + ); + if (saved == true) _reload(); + } + + Future _deleteService(ServiceRecord record) async { + final ok = await _confirm("Delete this service record?"); + if (!ok) return; + try { + await apiClient.deleteService(record.id); + _reload(); + } catch (e) { + _snack("Delete failed: $e"); + } + } + + Future _editCar(Car car) async { + final updated = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => CarFormSheet(car: car), + ); + if (updated != null) _reload(); + } + + Future _addPart(Car car) async { + final saved = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _PartSheet(carId: car.id), + ); + if (saved == true) _reload(); + } + + Future _editPart(Car car, Part part) async { + final saved = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _PartSheet(carId: car.id, part: part), + ); + if (saved == true) _reload(); + } + + Future _deletePart(Part part) async { + final ok = await _confirm("Delete this part?"); + if (!ok) return; + try { + await apiClient.deletePart(part.id); + _reload(); + } catch (e) { + _snack("Delete failed: $e"); + } + } + + Future _confirm(String message) async { + final res = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(message), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), + onPressed: () => Navigator.pop(ctx, true), + child: const Text("Delete"), + ), + ], + ), + ); + return res == true; + } + + void _snack(String msg) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + + Future _shareCar(Car car) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _ShareSheet(car: car), + ); + } + + Future _editOdometer(Car car) async { + final controller = TextEditingController(text: car.currentKm > 0 ? "${car.currentKm}" : ""); + final saved = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Current odometer"), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration(suffixText: "km", border: OutlineInputBorder()), + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), + FilledButton( + onPressed: () async { + await apiClient.updateCar( + car.id, + _carPayload(car, currentKm: int.tryParse(controller.text.trim()) ?? 0), + ); + if (ctx.mounted) Navigator.pop(ctx, true); + }, + child: const Text("Save"), + ), + ], + ), + ); + if (saved == true) _reload(); + } + + Future _deleteCar(Car car, int serviceCount, int partCount) async { + final confirmed = await showDialog( + context: context, + builder: (_) => _DeleteCarDialog( + car: car, + serviceCount: serviceCount, + partCount: partCount, + ), + ); + if (confirmed != true) return; + try { + await apiClient.deleteCar(car.id); + if (mounted) Navigator.pop(context); // back to dashboard (it refreshes) + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text("Delete failed: $e"))); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: FutureBuilder<_CarDetailData>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + if (snap.hasError) { + return Scaffold( + appBar: AppBar(), + body: Center(child: Text("${snap.error}")), + ); + } + final data = snap.data!; + final car = data.car; + final latest = data.services.isNotEmpty ? data.services.first : null; + final status = serviceStatus(latest, car); + + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: Text(car.name), + actions: [ + if (car.isOwner) + IconButton( + tooltip: "Share car", + icon: const Icon(Icons.person_add_alt), + onPressed: () => _shareCar(car), + ), + if (car.canWrite) + IconButton( + tooltip: "Edit car", + icon: const Icon(Icons.edit_outlined), + onPressed: () => _editCar(car), + ), + if (car.canWrite) + IconButton( + tooltip: "Update odometer", + icon: const Icon(Icons.speed), + onPressed: () => _editOdometer(car), + ), + if (car.isOwner) + IconButton( + tooltip: "Delete car", + icon: const Icon(Icons.delete_outline), + onPressed: () => + _deleteCar(car, data.services.length, data.parts.length), + ), + ], + bottom: const TabBar( + isScrollable: true, + tabs: [ + Tab(text: "Information"), + Tab(text: "Service history"), + Tab(text: "Parts catalog"), + ], + ), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Column( + children: [ + _StatusHeader(car: car, status: status), + if (car.isReadOnly) ...[ + const SizedBox(height: 12), + const _ReadOnlyNotice(), + ], + ], + ), + ), + Expanded( + child: TabBarView( + children: [ + // Information tab + _InfoTab(car: car, latest: latest), + // Service history tab + _TabList( + empty: data.services.isEmpty ? "No service records yet." : null, + onAdd: car.canWrite ? () => _addService(car) : null, + addLabel: "Add service", + children: data.services + .map((s) => _ServiceTile( + record: s, + onEdit: car.canWrite ? () => _editService(car, s) : null, + onDelete: car.canWrite ? () => _deleteService(s) : null, + )) + .toList(), + ), + // Parts catalog tab + _TabList( + empty: data.parts.isEmpty ? "No parts yet." : null, + onAdd: car.canWrite ? () => _addPart(car) : null, + addLabel: "Add part", + children: data.parts + .map((p) => _PartTile( + part: p, + onEdit: car.canWrite ? () => _editPart(car, p) : null, + onDelete: car.canWrite ? () => _deletePart(p) : null, + )) + .toList(), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +/// A scrollable tab body: an optional "+ Add" button, an empty-state message, or +/// the list of tiles. +class _TabList extends StatelessWidget { + final String? empty; + final VoidCallback? onAdd; + final String addLabel; + final List children; + const _TabList({ + required this.empty, + required this.onAdd, + required this.addLabel, + required this.children, + }); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + children: [ + if (onAdd != null) + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.add, size: 18), + label: Text(addLabel), + ), + ), + const SizedBox(height: 8), + if (empty != null) _Empty(empty!) else ...children, + ], + ); + } +} + +/// Slim header shown above the tabs: car subtitle (make/model) plus the +/// service-status badge. The car's spec details now live in the Information tab. +class _StatusHeader extends StatelessWidget { + final Car car; + final Status status; + const _StatusHeader({required this.car, required this.status}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Expanded( + child: Text(car.subtitle.isEmpty ? car.name : car.subtitle, + style: TextStyle(color: DriverVault.muted(context))), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: status.bg(DriverVault.isDark(context)), borderRadius: BorderRadius.circular(999)), + child: Text(status.label, + style: TextStyle(color: status.fg(DriverVault.isDark(context)), fontWeight: FontWeight.w600)), + ), + ], + ), + ), + ); + } +} + +/// Information tab: the car's spec details (oil specs, odometer, interval, next +/// due, VIN) in a scrollable card. +class _InfoTab extends StatelessWidget { + final Car car; + final ServiceRecord? latest; + const _InfoTab({required this.car, required this.latest}); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + children: [ + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _kv(context, "Engine oil spec", _orDash(car.oilSpec)), + _kv(context, "Transmission oil spec", _orDash(car.transmissionOilSpec)), + _kv(context, "Differential oil spec", _orDash(car.differentialOilSpec)), + _kv(context, "Brake fluid spec", _orDash(car.brakeFluidSpec)), + _kv(context, "Coolant spec", _orDash(car.coolantSpec)), + _kv(context, "Current odometer", formatKm(car.currentKm)), + _kv(context, "Service interval", "${car.serviceIntervalDays} days · ${formatKm(car.serviceIntervalKm)}"), + _kv(context, "Next due", "${formatDate(latest?.nextServiceDate)} · ${formatKm(latest?.nextServiceKm)}"), + _kv(context, "Registration plate", _orDash(car.registration)), + _kv(context, "Registration country", _orDash(car.registrationCountry)), + _kv(context, "VIN", _orDash(car.vin)), + _kv(context, "Fuel type", _fuelLabel(car.fuelType)), + _kv(context, "Build date", _dateOrDash(car.buildDate)), + _kv(context, "First registration", _dateOrDash(car.firstRegistrationDate)), + ], + ), + ), + ), + ], + ); + } + + static String _orDash(String v) => v.isEmpty ? "—" : v; + + static const Map _fuelLabels = { + "petrol": "Petrol (gasoline)", + "diesel": "Diesel", + "hybrid": "Hybrid", + "electric": "Electric", + }; + static String _fuelLabel(String v) => _fuelLabels[v] ?? "—"; + + static String _dateOrDash(String iso) { + final d = DateTime.tryParse(iso); + return d == null ? "—" : formatDate(d); + } + + Widget _kv(BuildContext context, String k, String v) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 150, child: Text(k, style: TextStyle(color: DriverVault.muted(context)))), + Expanded(child: Text(v, style: DriverVault.mono(context, size: 13, weight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface))), + ], + ), + ); +} + +class _ServiceTile extends StatelessWidget { + final ServiceRecord record; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + const _ServiceTile({required this.record, this.onEdit, this.onDelete}); + + @override + Widget build(BuildContext context) { + final chips = [ + if (record.changedOil) _chip(context, "Oil & filter"), + if (record.changedEngineAirFilter) _chip(context, "Engine air"), + if (record.changedCabinAirFilter) _chip(context, "Cabin air"), + ]; + return Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(formatDate(record.date), style: const TextStyle(fontWeight: FontWeight.w600)), + Row(children: [ + Text(formatKm(record.km)), + if (onEdit != null || onDelete != null) + _RowMenu(onEdit: onEdit, onDelete: onDelete), + ]), + ], + ), + const SizedBox(height: 4), + Text("Next: ${formatDate(record.nextServiceDate)} · ${formatKm(record.nextServiceKm)}", + style: const TextStyle(color: Colors.grey, fontSize: 12)), + if (chips.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 6, runSpacing: 6, children: chips), + ], + if (record.notes.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(record.notes, style: const TextStyle(fontSize: 12, fontStyle: FontStyle.italic)), + ], + ], + ), + ), + ); + } + + Widget _chip(BuildContext context, String label) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: DriverVault.isDark(context) ? DriverVault.successSoftDark : DriverVault.successSoft, + borderRadius: BorderRadius.circular(6), + ), + child: Text(label, style: const TextStyle(color: DriverVault.success, fontSize: 11, fontWeight: FontWeight.w500)), + ); +} + +class _PartTile extends StatelessWidget { + final Part part; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + const _PartTile({required this.part, this.onEdit, this.onDelete}); + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 6), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), + ), + child: ListTile( + dense: true, + title: Text(part.name, style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text(part.partNumber.isEmpty ? "—" : part.partNumber, + style: DriverVault.mono(context, size: 12, weight: FontWeight.w400, color: DriverVault.muted(context))), + trailing: (onEdit != null || onDelete != null) + ? _RowMenu(onEdit: onEdit, onDelete: onDelete) + : null, + ), + ); + } +} + +/// Overflow menu with Edit/Delete for a service/part row (write-gated). +class _RowMenu extends StatelessWidget { + final VoidCallback? onEdit; + final VoidCallback? onDelete; + const _RowMenu({this.onEdit, this.onDelete}); + @override + Widget build(BuildContext context) { + return PopupMenuButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.more_vert, size: 18), + onSelected: (v) { + if (v == "edit") onEdit?.call(); + if (v == "delete") onDelete?.call(); + }, + itemBuilder: (_) => [ + if (onEdit != null) const PopupMenuItem(value: "edit", child: Text("Edit")), + if (onDelete != null) + const PopupMenuItem( + value: "delete", child: Text("Delete", style: TextStyle(color: DriverVault.danger))), + ], + ); + } +} + +class _Empty extends StatelessWidget { + final String text; + const _Empty(this.text); + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center(child: Text(text, style: const TextStyle(color: Colors.grey))), + ); +} + +/// Shown on a car that was shared with the current user read-only. +class _ReadOnlyNotice extends StatelessWidget { + const _ReadOnlyNotice(); + @override + Widget build(BuildContext context) { + final onTint = DriverVault.brandOnTint(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: DriverVault.brandTint(context), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(Icons.visibility_outlined, size: 18, color: onTint), + const SizedBox(width: 8), + Expanded( + child: Text( + "Shared with you (read-only). You can't make changes.", + style: TextStyle(color: onTint, fontSize: 13), + ), + ), + ], + ), + ); + } +} + +/// Owner-only bottom sheet to manage who a car is shared with: list current +/// grants, add one by email at a chosen permission, or remove one. +class _ShareSheet extends StatefulWidget { + final Car car; + const _ShareSheet({required this.car}); + @override + State<_ShareSheet> createState() => _ShareSheetState(); +} + +class _ShareSheetState extends State<_ShareSheet> { + late Future> _future; + final _email = TextEditingController(); + String _permission = "read"; + bool _submitting = false; + String? _error; + + @override + void initState() { + super.initState(); + _future = apiClient.listCarShares(widget.car.id); + } + + @override + void dispose() { + _email.dispose(); + super.dispose(); + } + + void _reload() => setState(() => _future = apiClient.listCarShares(widget.car.id)); + + Future _add() async { + final email = _email.text.trim(); + if (email.isEmpty) return; + setState(() { + _submitting = true; + _error = null; + }); + try { + await apiClient.addCarShare(widget.car.id, email, _permission); + _email.clear(); + _permission = "read"; + _reload(); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Future _setPermission(CarShare s, String value) async { + try { + await apiClient.addCarShare(widget.car.id, s.email, value); + _reload(); + } catch (e) { + setState(() => _error = e.toString()); + } + } + + Future _remove(CarShare s) async { + try { + await apiClient.removeCarShare(widget.car.id, s.userId); + _reload(); + } catch (e) { + setState(() => _error = e.toString()); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Share ${widget.car.name}", + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text( + "Read-only lets them view; read & write also lets them edit the car and its records.", + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + const SizedBox(height: 12), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), + ), + Row( + children: [ + Expanded( + child: TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: "User email", + border: OutlineInputBorder(), + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + DropdownButton( + value: _permission, + onChanged: (v) => setState(() => _permission = v ?? "read"), + items: const [ + DropdownMenuItem(value: "read", child: Text("Read")), + DropdownMenuItem(value: "write", child: Text("Write")), + ], + ), + ], + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _submitting ? null : _add, + child: Text(_submitting ? "Sharing…" : "Share"), + ), + ), + const SizedBox(height: 16), + const Text("People with access", + style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + final shares = snap.data ?? []; + if (shares.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text("Not shared with anyone yet.", + style: TextStyle(color: Colors.grey)), + ); + } + return Column( + children: shares + .map((s) => ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(s.label), + subtitle: s.name.isNotEmpty ? Text(s.email) : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButton( + value: s.permission, + underline: const SizedBox.shrink(), + onChanged: (v) => v == null ? null : _setPermission(s, v), + items: const [ + DropdownMenuItem(value: "read", child: Text("Read")), + DropdownMenuItem(value: "write", child: Text("Write")), + ], + ), + IconButton( + tooltip: "Remove", + icon: const Icon(Icons.close, size: 18), + onPressed: () => _remove(s), + ), + ], + ), + )) + .toList(), + ); + }, + ), + ], + ), + ); + } +} + +/// Type-to-confirm dialog for deleting a car. The delete button is enabled only +/// once the user types the car's exact name — guarding this cascade delete +/// (which also removes all of the car's service records and parts). +class _DeleteCarDialog extends StatefulWidget { + final Car car; + final int serviceCount; + final int partCount; + const _DeleteCarDialog({ + required this.car, + required this.serviceCount, + required this.partCount, + }); + @override + State<_DeleteCarDialog> createState() => _DeleteCarDialogState(); +} + +class _DeleteCarDialogState extends State<_DeleteCarDialog> { + final _confirm = TextEditingController(); + bool _deleting = false; + + @override + void dispose() { + _confirm.dispose(); + super.dispose(); + } + + bool get _canDelete => _confirm.text.trim() == widget.car.name; + + String _plural(int n, String noun) => "$n $noun${n == 1 ? '' : 's'}"; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Delete this car?", style: TextStyle(color: DriverVault.danger, fontWeight: FontWeight.w700)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "This permanently deletes ${widget.car.name} and all of its " + "${_plural(widget.serviceCount, 'service record')} and " + "${_plural(widget.partCount, 'part')}. This cannot be undone.", + ), + const SizedBox(height: 16), + Text("Type “${widget.car.name}” to confirm", + style: const TextStyle(fontSize: 13, color: Colors.grey)), + const SizedBox(height: 6), + TextField( + controller: _confirm, + autofocus: true, + decoration: InputDecoration(hintText: widget.car.name, border: const OutlineInputBorder()), + onChanged: (_) => setState(() {}), + ), + ], + ), + actions: [ + TextButton( + onPressed: _deleting ? null : () => Navigator.pop(context, false), + child: const Text("Cancel"), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: const Color(0xFFDC2626)), + onPressed: (!_canDelete || _deleting) + ? null + : () { + setState(() => _deleting = true); + Navigator.pop(context, true); + }, + child: Text(_deleting ? "Deleting…" : "Delete permanently"), + ), + ], + ); + } +} + +/// Bottom sheet for adding or editing a service record. +class _ServiceSheet extends StatefulWidget { + final String carId; + final Car car; + final ServiceRecord? record; // null => create + const _ServiceSheet({required this.carId, required this.car, this.record}); + @override + State<_ServiceSheet> createState() => _ServiceSheetState(); +} + +class _ServiceSheetState extends State<_ServiceSheet> { + late DateTime _date; + late final TextEditingController _km; + late final TextEditingController _notes; + late bool _oil, _engine, _cabin; + bool _saving = false; + String? _error; + + bool get _isEdit => widget.record != null; + + @override + void initState() { + super.initState(); + final r = widget.record; + _date = r?.date ?? DateTime.now(); + _km = TextEditingController(text: (r != null && r.km > 0) ? "${r.km}" : ""); + _notes = TextEditingController(text: r?.notes ?? ""); + _oil = r?.changedOil ?? true; + _engine = r?.changedEngineAirFilter ?? false; + _cabin = r?.changedCabinAirFilter ?? false; + } + + @override + void dispose() { + _km.dispose(); + _notes.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + final payload = { + "car": widget.carId, + "date": _date.toUtc().toIso8601String(), + "km": int.tryParse(_km.text.trim()) ?? 0, + "changedOil": _oil, + "changedEngineAirFilter": _engine, + "changedCabinAirFilter": _cabin, + "notes": _notes.text.trim(), + }; + try { + if (_isEdit) { + await apiClient.updateService(widget.record!.id, payload); + } else { + await apiClient.createService(payload); + } + if (mounted) Navigator.pop(context, true); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_isEdit ? "Edit service record" : "Add service record", + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), + ), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 16), + label: Text(formatDate(_date)), + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _date, + firstDate: DateTime(2000), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) setState(() => _date = picked); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _km, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: "Odometer (km)", border: OutlineInputBorder()), + ), + ), + ], + ), + const SizedBox(height: 8), + CheckboxListTile( + value: _oil, + onChanged: (v) => setState(() => _oil = v ?? false), + title: const Text("Oil & oil filter"), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + CheckboxListTile( + value: _engine, + onChanged: (v) => setState(() => _engine = v ?? false), + title: const Text("Engine air filter"), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + CheckboxListTile( + value: _cabin, + onChanged: (v) => setState(() => _cabin = v ?? false), + title: const Text("Cabin air filter"), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + TextField( + controller: _notes, + decoration: const InputDecoration(labelText: "Notes", border: OutlineInputBorder()), + ), + const SizedBox(height: 8), + Text( + "Next service (+${widget.car.serviceIntervalDays}d / +${formatKm(widget.car.serviceIntervalKm)}) is computed automatically.", + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add service")), + ), + ), + ), + ], + ), + ); + } +} + +/// Bottom sheet for adding or editing a part. +class _PartSheet extends StatefulWidget { + final String carId; + final Part? part; // null => create + const _PartSheet({required this.carId, this.part}); + @override + State<_PartSheet> createState() => _PartSheetState(); +} + +class _PartSheetState extends State<_PartSheet> { + late final TextEditingController _name; + late final TextEditingController _partNumber; + bool _saving = false; + String? _error; + + bool get _isEdit => widget.part != null; + + @override + void initState() { + super.initState(); + _name = TextEditingController(text: widget.part?.name ?? ""); + _partNumber = TextEditingController(text: widget.part?.partNumber ?? ""); + } + + @override + void dispose() { + _name.dispose(); + _partNumber.dispose(); + super.dispose(); + } + + Future _save() async { + final name = _name.text.trim(); + if (name.isEmpty) { + setState(() => _error = "Part name is required."); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final payload = { + "car": widget.carId, + "name": name, + "partNumber": _partNumber.text.trim(), + }; + try { + if (_isEdit) { + await apiClient.updatePart(widget.part!.id, payload); + } else { + await apiClient.createPart(payload); + } + if (mounted) Navigator.pop(context, true); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_isEdit ? "Edit part" : "Add part", + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), + ), + TextField( + controller: _name, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration(labelText: "Part name *", border: OutlineInputBorder()), + ), + const SizedBox(height: 8), + TextField( + controller: _partNumber, + decoration: const InputDecoration(labelText: "Part number", border: OutlineInputBorder()), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add part")), + ), + ), + ), + ], + ), + ); + } +} diff --git a/Phone App/lib/screens/car_form_sheet.dart b/Phone App/lib/screens/car_form_sheet.dart new file mode 100644 index 0000000..19d4041 --- /dev/null +++ b/Phone App/lib/screens/car_form_sheet.dart @@ -0,0 +1,249 @@ +import "package:flutter/material.dart"; + +import "../main.dart"; +import "../models.dart"; +import "../format.dart"; +import "../theme.dart"; + +/// Bottom-sheet form to create or edit a car, mirroring the web CarFormModal. +/// Pops with the saved [Car] on success, or null if cancelled. +class CarFormSheet extends StatefulWidget { + final Car? car; // null => create + const CarFormSheet({super.key, this.car}); + + @override + State createState() => _CarFormSheetState(); +} + +class _CarFormSheetState extends State { + late final Map _c; + String _fuelType = ""; + DateTime? _buildDate; + DateTime? _firstRegistrationDate; + bool _saving = false; + String? _error; + + bool get _isEdit => widget.car != null; + + @override + void initState() { + super.initState(); + final car = widget.car; + _fuelType = car?.fuelType ?? ""; + _buildDate = (car?.buildDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.buildDate) : null; + _firstRegistrationDate = + (car?.firstRegistrationDate.isNotEmpty ?? false) ? DateTime.tryParse(car!.firstRegistrationDate) : null; + _c = { + "name": TextEditingController(text: car?.name ?? ""), + "make": TextEditingController(text: car?.make ?? ""), + "model": TextEditingController(text: car?.model ?? ""), + "year": TextEditingController(text: (car != null && car.year > 0) ? "${car.year}" : ""), + "registration": TextEditingController(text: car?.registration ?? ""), + "registrationCountry": TextEditingController(text: car?.registrationCountry ?? ""), + "vin": TextEditingController(text: car?.vin ?? ""), + "oilSpec": TextEditingController(text: car?.oilSpec ?? ""), + "transmissionOilSpec": TextEditingController(text: car?.transmissionOilSpec ?? ""), + "differentialOilSpec": TextEditingController(text: car?.differentialOilSpec ?? ""), + "brakeFluidSpec": TextEditingController(text: car?.brakeFluidSpec ?? ""), + "coolantSpec": TextEditingController(text: car?.coolantSpec ?? ""), + "currentKm": + TextEditingController(text: (car != null && car.currentKm > 0) ? "${car.currentKm}" : ""), + "serviceIntervalDays": TextEditingController(text: "${car?.serviceIntervalDays ?? 365}"), + "serviceIntervalKm": TextEditingController(text: "${car?.serviceIntervalKm ?? 15000}"), + }; + } + + @override + void dispose() { + for (final c in _c.values) { + c.dispose(); + } + super.dispose(); + } + + int _int(String key, int fallback) => int.tryParse(_c[key]!.text.trim()) ?? fallback; + + Future _save() async { + final name = _c["name"]!.text.trim(); + if (name.isEmpty) { + setState(() => _error = "Name is required."); + return; + } + setState(() { + _saving = true; + _error = null; + }); + final payload = { + "name": name, + "make": _c["make"]!.text.trim(), + "model": _c["model"]!.text.trim(), + "year": _int("year", 0), + "registration": _c["registration"]!.text.trim(), + "registrationCountry": _c["registrationCountry"]!.text.trim(), + "vin": _c["vin"]!.text.trim(), + "fuelType": _fuelType, + "buildDate": _isoOrEmpty(_buildDate), + "firstRegistrationDate": _isoOrEmpty(_firstRegistrationDate), + "oilSpec": _c["oilSpec"]!.text.trim(), + "transmissionOilSpec": _c["transmissionOilSpec"]!.text.trim(), + "differentialOilSpec": _c["differentialOilSpec"]!.text.trim(), + "brakeFluidSpec": _c["brakeFluidSpec"]!.text.trim(), + "coolantSpec": _c["coolantSpec"]!.text.trim(), + "currentKm": _int("currentKm", 0), + "serviceIntervalDays": _int("serviceIntervalDays", 365), + "serviceIntervalKm": _int("serviceIntervalKm", 15000), + }; + try { + final saved = _isEdit + ? await apiClient.updateCar(widget.car!.id, payload) + : await apiClient.createCar(payload); + if (mounted) Navigator.pop(context, saved); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + static String _isoOrEmpty(DateTime? d) => d == null + ? "" + : "${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}"; + + Widget _field(String key, String label, + {TextInputType? keyboard, TextCapitalization caps = TextCapitalization.none}) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: TextField( + controller: _c[key], + keyboardType: keyboard, + textCapitalization: caps, + decoration: InputDecoration(labelText: label, border: const OutlineInputBorder(), isDense: true), + ), + ); + } + + /// A tappable read-only field that opens a date picker. Shows a clear button + /// when a date is set, otherwise a calendar icon. + Widget _dateField(String label, DateTime? value, ValueChanged onChanged) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: InkWell( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: value ?? DateTime.now(), + firstDate: DateTime(1950), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) setState(() => onChanged(picked)); + }, + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + isDense: true, + suffixIcon: value == null + ? const Icon(Icons.calendar_today, size: 18) + : IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () => setState(() => onChanged(null)), + ), + ), + child: Text(value == null ? "—" : formatDate(value)), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_isEdit ? "Edit car" : "Add a car", + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), + ), + _field("name", "Name *", caps: TextCapitalization.words), + Row(children: [ + Expanded(child: _field("make", "Make")), + const SizedBox(width: 8), + Expanded(child: _field("model", "Model")), + ]), + Row(children: [ + Expanded(child: _field("year", "Year", keyboard: TextInputType.number)), + const SizedBox(width: 8), + Expanded(child: _field("registration", "Registration")), + ]), + _field("registrationCountry", "Registration country", caps: TextCapitalization.words), + _field("vin", "VIN"), + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: DropdownButtonFormField( + initialValue: _fuelType.isEmpty ? null : _fuelType, + decoration: const InputDecoration( + labelText: "Fuel type", border: OutlineInputBorder(), isDense: true), + items: const [ + DropdownMenuItem(value: "petrol", child: Text("Petrol (gasoline)")), + DropdownMenuItem(value: "diesel", child: Text("Diesel")), + DropdownMenuItem(value: "hybrid", child: Text("Hybrid")), + DropdownMenuItem(value: "electric", child: Text("Electric")), + ], + onChanged: (v) => setState(() => _fuelType = v ?? ""), + ), + ), + Row(children: [ + Expanded(child: _dateField("Build date", _buildDate, (v) => _buildDate = v)), + const SizedBox(width: 8), + Expanded( + child: _dateField("First registration", _firstRegistrationDate, + (v) => _firstRegistrationDate = v)), + ]), + _field("oilSpec", "Engine oil spec"), + Row(children: [ + Expanded(child: _field("transmissionOilSpec", "Transmission oil spec")), + const SizedBox(width: 8), + Expanded(child: _field("differentialOilSpec", "Differential oil spec")), + ]), + Row(children: [ + Expanded(child: _field("brakeFluidSpec", "Brake fluid spec")), + const SizedBox(width: 8), + Expanded(child: _field("coolantSpec", "Coolant spec")), + ]), + _field("currentKm", "Current odometer (km)", keyboard: TextInputType.number), + Row(children: [ + Expanded( + child: _field("serviceIntervalDays", "Service interval (days)", + keyboard: TextInputType.number)), + const SizedBox(width: 8), + Expanded( + child: _field("serviceIntervalKm", "Service interval (km)", + keyboard: TextInputType.number)), + ]), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: Text(_saving ? "Saving…" : (_isEdit ? "Save changes" : "Add car")), + ), + ), + ], + ), + ), + ); + } +} diff --git a/Phone App/lib/screens/dashboard_screen.dart b/Phone App/lib/screens/dashboard_screen.dart new file mode 100644 index 0000000..306b76d --- /dev/null +++ b/Phone App/lib/screens/dashboard_screen.dart @@ -0,0 +1,355 @@ +import "package:flutter/material.dart"; + +import "../main.dart"; +import "../models.dart"; +import "../format.dart"; +import "../theme.dart"; +import "car_detail_screen.dart"; +import "car_form_sheet.dart"; + +class _CarRow { + final Car car; + final ServiceRecord? latest; + final int count; + _CarRow(this.car, this.latest, this.count); +} + +class DashboardScreen extends StatefulWidget { + const DashboardScreen({super.key}); + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _load(); + } + + Future> _load() async { + final cars = await apiClient.listCars(); + final rows = <_CarRow>[]; + for (final c in cars) { + final services = await apiClient.listCarServices(c.id); + rows.add(_CarRow(c, services.isNotEmpty ? services.first : null, services.length)); + } + return rows; + } + + Future _refresh() async { + final f = _load(); + setState(() => _future = f); + await f; + } + + Future _addCar() async { + final created = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => const CarFormSheet(), + ); + if (created != null && mounted) { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => CarDetailScreen(carId: created.id)), + ); + await _refresh(); + } + } + + void _toggleTheme() { + final next = Theme.of(context).brightness == Brightness.dark ? "light" : "dark"; + appSettings.patch(theme: next); // optimistic + persisted locally + apiClient.updateMe({"theme": next}).then((_) {}).catchError((_) {}); + } + + @override + Widget build(BuildContext context) { + final dark = DriverVault.isDark(context); + return Scaffold( + floatingActionButton: FloatingActionButton.extended( + onPressed: _addCar, + icon: const Icon(Icons.add), + label: const Text("Add car"), + ), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Home header — eyebrow + title on the left, quick actions right. + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 16, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("YOUR CARS", + style: DriverVault.mono(context, + size: 10, weight: FontWeight.w500, color: DriverVault.muted(context)) + .copyWith(letterSpacing: 2.2)), + const SizedBox(height: 2), + const Text("Garage", + style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, letterSpacing: -0.5)), + ], + ), + ), + _HeaderButton( + icon: dark ? Icons.light_mode_outlined : Icons.dark_mode_outlined, + tooltip: dark ? "Light mode" : "Dark mode", + onTap: _toggleTheme, + ), + const SizedBox(width: 8), + _HeaderButton( + icon: Icons.logout, + tooltip: "Log out", + onTap: () => authService.logout(), + ), + ], + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _refresh, + child: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return _ErrorView(message: "${snap.error}", onRetry: _refresh); + } + final rows = snap.data ?? []; + if (rows.isEmpty) { + return ListView(children: const [ + SizedBox(height: 80), + Center(child: Text("No cars yet.")), + ]); + } + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 96), + itemCount: rows.length, + itemBuilder: (context, i) => _CarCard(row: rows[i], onChanged: _refresh), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// A bordered 40×40 square icon button used in the home header (mockup style). +class _HeaderButton extends StatelessWidget { + final IconData icon; + final String tooltip; + final VoidCallback onTap; + const _HeaderButton({required this.icon, required this.tooltip, required this.onTap}); + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Material( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + child: InkWell( + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + onTap: onTap, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + border: Border.all(color: DriverVault.isDark(context) ? DriverVault.darkBorderStrong : DriverVault.ink200), + ), + child: Icon(icon, size: 19, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ), + ); + } +} + +class _CarCard extends StatelessWidget { + final _CarRow row; + final Future Function() onChanged; + const _CarCard({required this.row, required this.onChanged}); + + @override + Widget build(BuildContext context) { + final status = serviceStatus(row.latest, row.car); + return Card( + margin: const EdgeInsets.only(bottom: 10), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: DriverVault.isDark(context) ? DriverVault.darkBorder : DriverVault.ink100), + ), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => CarDetailScreen(carId: row.car.id)), + ); + await onChanged(); + }, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(row.car.name, + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 16, letterSpacing: -0.3)), + if (row.car.subtitle.isNotEmpty) + Text(row.car.subtitle, + style: TextStyle(color: DriverVault.muted(context), fontSize: 12)), + ], + ), + ), + _Badge(status: status), + ], + ), + if (!row.car.isOwner) ...[ + const SizedBox(height: 8), + _SharedChip(readOnly: row.car.isReadOnly), + ], + ..._serviceLife(context, status), + const SizedBox(height: 12), + _kv(context, "Last service", formatDate(row.latest?.date)), + _kv(context, "Current odometer", formatKm(row.car.currentKm)), + _kv(context, "Next due", formatDate(row.latest?.nextServiceDate)), + _kv(context, "Next due km", formatKm(row.latest?.nextServiceKm)), + const SizedBox(height: 6), + Text("${row.count} service record${row.count == 1 ? '' : 's'}", + style: TextStyle(color: DriverVault.muted(context), fontSize: 11)), + ], + ), + ), + ), + ); + } + + Widget _kv(BuildContext context, String k, String v) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(k, style: TextStyle(color: DriverVault.muted(context), fontSize: 13)), + Text(v, style: DriverVault.mono(context, size: 13, weight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface)), + ], + ), + ); + + /// Service-life bar: fraction of the km interval used up (echoes the mockup's + /// battery bar). Returns [] when there isn't enough data to compute it. + List _serviceLife(BuildContext context, Status status) { + final interval = row.car.serviceIntervalKm; + final nextKm = row.latest?.nextServiceKm ?? 0; + final currentKm = row.car.currentKm; + if (interval <= 0 || nextKm <= 0 || currentKm <= 0) return const []; + final remaining = nextKm - currentKm; + final pct = (100 * (1 - remaining / interval)).clamp(0, 100).round(); + final tone = status.fg(DriverVault.isDark(context)); + return [ + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("SERVICE LIFE", + style: DriverVault.mono(context, size: 9, weight: FontWeight.w500, + color: DriverVault.muted(context)).copyWith(letterSpacing: 1.6)), + Text("$pct%", + style: DriverVault.mono(context, size: 11, weight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface)), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + value: pct / 100, + minHeight: 7, + backgroundColor: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink100, + valueColor: AlwaysStoppedAnimation(tone), + ), + ), + ]; + } +} + +class _SharedChip extends StatelessWidget { + final bool readOnly; + const _SharedChip({required this.readOnly}); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: DriverVault.brandTint(context), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + readOnly ? "Shared · read-only" : "Shared", + style: TextStyle(color: DriverVault.brandOnTint(context), fontSize: 11, fontWeight: FontWeight.w600), + ), + ); + } +} + +class _Badge extends StatelessWidget { + final Status status; + const _Badge({required this.status}); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: status.bg(DriverVault.isDark(context)), borderRadius: BorderRadius.circular(999)), + child: Text(status.label, + style: TextStyle(color: status.fg(DriverVault.isDark(context)), fontSize: 12, fontWeight: FontWeight.w600)), + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final Future Function() onRetry; + const _ErrorView({required this.message, required this.onRetry}); + @override + Widget build(BuildContext context) { + return ListView( + children: [ + const SizedBox(height: 80), + Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + const Icon(Icons.error_outline, color: DriverVault.danger, size: 40), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton(onPressed: onRetry, child: const Text("Retry")), + ], + ), + ), + ), + ], + ); + } +} diff --git a/Phone App/lib/screens/lock_screen.dart b/Phone App/lib/screens/lock_screen.dart new file mode 100644 index 0000000..3a4cbe6 --- /dev/null +++ b/Phone App/lib/screens/lock_screen.dart @@ -0,0 +1,110 @@ +import "package:flutter/material.dart"; + +import "../biometric.dart"; +import "../main.dart"; +import "../theme.dart"; + +/// Shown when the app is authenticated but locked (biometric login is enabled +/// and the app was just launched or resumed). The session token is still valid; +/// a successful biometric check simply reveals the app. "Use password" drops the +/// session and returns to the login screen. +class LockScreen extends StatefulWidget { + const LockScreen({super.key}); + @override + State createState() => _LockScreenState(); +} + +class _LockScreenState extends State { + bool _busy = false; + String? _error; + + @override + void initState() { + super.initState(); + // Prompt once as soon as the lock screen appears. + WidgetsBinding.instance.addPostFrameCallback((_) => _unlock()); + } + + Future _unlock() async { + if (_busy) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final creds = await biometricAuth.unlock(reason: "Unlock DriverVault"); + if (creds == null) { + // Cancelled — leave locked; the buttons let them retry or use password. + if (mounted) setState(() => _busy = false); + return; + } + // Token from a previous session is still held by ApiClient; just reveal. + authService.unlock(); + } on BiometricException catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final u = authService.user; + final name = u == null ? null : (u.name.isNotEmpty ? u.name : u.email); + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: DriverVault.brand700, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.lock_outline, color: Colors.white, size: 32), + ), + const SizedBox(height: 16), + const Text("DriverVault is locked", + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, letterSpacing: -0.4)), + if (name != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(name, style: TextStyle(color: DriverVault.muted(context))), + ), + const SizedBox(height: 24), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text(_error!, + textAlign: TextAlign.center, + style: const TextStyle(color: DriverVault.danger)), + ), + SizedBox( + width: 260, + child: FilledButton.icon( + onPressed: _busy ? null : _unlock, + icon: const Icon(Icons.fingerprint), + label: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Text(_busy ? "Unlocking…" : "Unlock"), + ), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _busy ? null : () => authService.logout(), + child: const Text("Use password instead"), + ), + ], + ), + ), + ), + ); + } +} diff --git a/Phone App/lib/screens/login_screen.dart b/Phone App/lib/screens/login_screen.dart new file mode 100644 index 0000000..cdfce2a --- /dev/null +++ b/Phone App/lib/screens/login_screen.dart @@ -0,0 +1,383 @@ +import "package:flutter/material.dart"; + +import "../api.dart"; +import "../biometric.dart"; +import "../config.dart"; +import "../main.dart"; +import "../theme.dart"; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _email = TextEditingController(); + final _password = TextEditingController(); + final _server = TextEditingController(); + final _formKey = GlobalKey(); + bool _loading = false; + bool _showPassword = false; + String? _error; + String? _serverSaved; + + // Biometric ("Face recognition" / "Fingerprint") sign-in state. + bool _bioAvailable = false; + bool _bioEnabled = false; + bool _hasFace = false; + bool _hasFingerprint = false; + String? _bioEmail; + bool _autoPrompted = false; + + @override + void initState() { + super.initState(); + apiClient.serverOverride().then((v) { + if (mounted) _server.text = v; + }); + _initBiometrics(); + } + + Future _initBiometrics() async { + final available = await biometricAuth.isAvailable(); + final email = await biometricAuth.enabledEmail(); + final face = available && await biometricAuth.hasFace(); + final finger = available && await biometricAuth.hasFingerprint(); + if (!mounted) return; + setState(() { + _bioAvailable = available; + _bioEnabled = email != null; + _bioEmail = email; + _hasFace = face; + _hasFingerprint = finger; + }); + // If biometric login is set up, offer it immediately (once) on screen load. + if (_bioEnabled && _bioAvailable && !_autoPrompted) { + _autoPrompted = true; + _biometricLogin(); + } + } + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + _server.dispose(); + super.dispose(); + } + + Future _saveServer() async { + await apiClient.setServerUrl(_server.text); + final current = await apiClient.serverOverride(); + if (!mounted) return; + setState(() { + _server.text = current; + _serverSaved = "Saved ✓"; + }); + } + + Future _resetServer() async { + await apiClient.setServerUrl(""); + if (!mounted) return; + setState(() { + _server.clear(); + _serverSaved = "Reset ✓"; + }); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _loading = true; + _error = null; + }); + final email = _email.text.trim(); + final password = _password.text; + try { + await authService.login(email, password); + // Pull the full profile so saved appearance prefs (theme/font/date) apply. + try { + appSettings.applyFromProfile(await apiClient.getMe()); + } catch (_) {} + // Offer to remember these (known-good) credentials for biometric login. + await _maybeOfferBiometricEnrollment(email, password); + // The app root rebuilds to the dashboard via the auth listener. + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// After a successful password login, if the device supports biometrics and + /// they aren't set up yet, ask whether to enable biometric sign-in. + Future _maybeOfferBiometricEnrollment(String email, String password) async { + if (!_bioAvailable || _bioEnabled || !mounted) return; + final method = _hasFace && !_hasFingerprint + ? "face recognition" + : _hasFingerprint && !_hasFace + ? "your fingerprint" + : "biometrics"; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Enable biometric sign-in?"), + content: Text( + "Next time you can sign in with $method instead of typing your password."), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Not now")), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text("Enable")), + ], + ), + ); + if (ok == true) { + await biometricAuth.enable(email, password); + } + } + + Future _biometricLogin() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final creds = await biometricAuth.unlock( + reason: _bioEmail != null ? "Sign in as $_bioEmail" : "Sign in to DriverVault", + ); + if (creds == null) { + // User cancelled the prompt, or nothing is stored. + if (mounted) setState(() => _loading = false); + return; + } + await authService.login(creds.$1, creds.$2); + try { + appSettings.applyFromProfile(await apiClient.getMe()); + } catch (_) {} + } on BiometricException catch (e) { + if (mounted) setState(() => _error = e.message); + } on ApiException catch (e) { + // Stored credentials no longer work (e.g. password changed) — turn off + // biometric login and fall back to the password form. + if (e.status == 400 || e.status == 401) { + await biometricAuth.disable(); + if (mounted) { + setState(() { + _bioEnabled = false; + _error = "Saved sign-in is no longer valid. Please sign in with your password."; + }); + } + } else if (mounted) { + setState(() => _error = e.message); + } + } catch (e) { + if (mounted) setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// Biometric sign-in buttons, shown only once biometric login is set up on + /// this device. Labels reflect the enrolled biometric kind(s). + Widget _buildBiometricSection() { + if (!_bioEnabled || !_bioAvailable) return const SizedBox.shrink(); + + final buttons = []; + Widget bioButton(IconData icon, String label) => Padding( + padding: const EdgeInsets.only(top: 8), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _loading ? null : _biometricLogin, + icon: Icon(icon), + label: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text(label), + ), + ), + ), + ); + + if (_hasFace) buttons.add(bioButton(Icons.face, "Sign in with face recognition")); + if (_hasFingerprint) buttons.add(bioButton(Icons.fingerprint, "Sign in with fingerprint")); + if (buttons.isEmpty) buttons.add(bioButton(Icons.lock_outline, "Sign in with biometrics")); + + return Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Expanded(child: Divider()), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text("or", style: TextStyle(color: Colors.grey, fontSize: 12)), + ), + Expanded(child: Divider()), + ], + ), + ), + ...buttons, + ], + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const DriverVaultLogo(markSize: 40, fontSize: 30), + const SizedBox(height: 10), + Text("Your car, on track.", + style: TextStyle(color: DriverVault.muted(context))), + const SizedBox(height: 24), + Form( + key: _formKey, + child: Column( + children: [ + if (_error != null) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Text(_error!, style: const TextStyle(color: DriverVault.danger)), + ), + TextFormField( + controller: _email, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder()), + validator: (v) => (v == null || v.isEmpty) ? "Required" : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _password, + obscureText: !_showPassword, + decoration: InputDecoration( + labelText: "Password", + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon(_showPassword ? Icons.visibility_off : Icons.visibility), + tooltip: _showPassword ? "Hide password" : "Show password", + onPressed: () => setState(() => _showPassword = !_showPassword), + ), + ), + validator: (v) => (v == null || v.isEmpty) ? "Required" : null, + onFieldSubmitted: (_) => _submit(), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _loading ? null : _submit, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text(_loading ? "Signing in…" : "Sign in"), + ), + ), + ), + ], + ), + ), + _buildBiometricSection(), + const SizedBox(height: 8), + _ServerSettings( + controller: _server, + savedNote: _serverSaved, + onSave: _saveServer, + onReset: _resetServer, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Collapsible "Server settings" on the login screen: an editable API server URL +/// override (blank = use the compile-time default). Mirrors the web app. +class _ServerSettings extends StatefulWidget { + final TextEditingController controller; + final String? savedNote; + final Future Function() onSave; + final Future Function() onReset; + const _ServerSettings({ + required this.controller, + required this.savedNote, + required this.onSave, + required this.onReset, + }); + @override + State<_ServerSettings> createState() => _ServerSettingsState(); +} + +class _ServerSettingsState extends State<_ServerSettings> { + bool _open = false; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => setState(() => _open = !_open), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Server settings", + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey)), + Icon(_open ? Icons.expand_less : Icons.expand_more, size: 18, color: Colors.grey), + ], + ), + ), + ), + if (_open) ...[ + TextField( + controller: widget.controller, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: const InputDecoration( + labelText: "API server URL", + hintText: kDefaultApiBase, + border: OutlineInputBorder(), + isDense: true, + ), + ), + const Padding( + padding: EdgeInsets.only(top: 4), + child: Text("Leave blank to use the default.", + style: TextStyle(color: Colors.grey, fontSize: 11)), + ), + const SizedBox(height: 4), + Row( + children: [ + OutlinedButton(onPressed: widget.onSave, child: const Text("Save")), + const SizedBox(width: 8), + TextButton(onPressed: widget.onReset, child: const Text("Reset")), + if (widget.savedNote != null) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text(widget.savedNote!, + style: const TextStyle(color: DriverVault.success, fontSize: 12)), + ), + ], + ), + ], + ], + ); + } +} diff --git a/Phone App/lib/screens/root_shell.dart b/Phone App/lib/screens/root_shell.dart new file mode 100644 index 0000000..c1023e7 --- /dev/null +++ b/Phone App/lib/screens/root_shell.dart @@ -0,0 +1,61 @@ +import "package:flutter/material.dart"; + +import "../main.dart"; +import "dashboard_screen.dart"; +import "settings_screen.dart"; +import "admin_users_screen.dart"; + +/// The app's root shell — a persistent bottom navigation bar (DriverVault mockup +/// layout) hosting the Garage, Settings and (for admins) Users sections in an +/// IndexedStack so each keeps its state as you switch tabs. +class RootShell extends StatefulWidget { + const RootShell({super.key}); + @override + State createState() => _RootShellState(); +} + +class _RootShellState extends State { + int _index = 0; + + @override + Widget build(BuildContext context) { + final isAdmin = authService.user?.isAdmin == true; + + final pages = [ + const DashboardScreen(), + const SettingsScreen(), + if (isAdmin) const AdminUsersScreen(), + ]; + + final destinations = [ + const NavigationDestination( + icon: Icon(Icons.grid_view_outlined), + selectedIcon: Icon(Icons.grid_view_rounded), + label: "Garage", + ), + const NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: "Settings", + ), + if (isAdmin) + const NavigationDestination( + icon: Icon(Icons.group_outlined), + selectedIcon: Icon(Icons.group), + label: "Users", + ), + ]; + + // Guard against the index falling out of range if admin status changes. + final index = _index.clamp(0, pages.length - 1); + + return Scaffold( + body: IndexedStack(index: index, children: pages), + bottomNavigationBar: NavigationBar( + selectedIndex: index, + onDestinationSelected: (i) => setState(() => _index = i), + destinations: destinations, + ), + ); + } +} diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart new file mode 100644 index 0000000..f8f6c86 --- /dev/null +++ b/Phone App/lib/screens/settings_screen.dart @@ -0,0 +1,915 @@ +import "dart:typed_data"; + +import "package:flutter/material.dart"; +import "package:image_picker/image_picker.dart"; + +import "../biometric.dart"; +import "../main.dart"; +import "../models.dart"; +import "../format.dart"; +import "../theme.dart"; + +/// Full settings panel, mirroring the web Settings.vue (minus data export/import): +/// account (name/email/password), appearance (theme/locale/date/font), profile +/// (avatar/bio), privacy (sessions), and account deletion. +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + UserProfile? _profile; + List _sessions = []; + Uint8List? _avatar; + bool _loading = true; + String? _loadError; + + final _name = TextEditingController(); + final _bio = TextEditingController(); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _name.dispose(); + _bio.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + _loading = true; + _loadError = null; + }); + try { + final p = await apiClient.getMe(); + appSettings.applyFromProfile(p); + final sessions = await apiClient.listSessions(); + Uint8List? avatar; + if (p.hasAvatar) { + final bytes = await apiClient.getAvatarBytes(); + if (bytes != null) avatar = Uint8List.fromList(bytes); + } + _name.text = p.name; + _bio.text = p.bio; + setState(() { + _profile = p; + _sessions = sessions; + _avatar = avatar; + }); + } catch (e) { + setState(() => _loadError = e.toString()); + } finally { + setState(() => _loading = false); + } + } + + void _snack(String msg) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("Settings")), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _loadError != null + ? Center(child: Text(_loadError!)) + : ListView( + padding: const EdgeInsets.all(12), + children: [ + _AccountSection( + profile: _profile!, + nameController: _name, + onChanged: _load, + snack: _snack, + ), + const SizedBox(height: 12), + _AppearanceSection(onError: _snack), + const SizedBox(height: 12), + _ProfileSection( + profile: _profile!, + bioController: _bio, + avatar: _avatar, + onChanged: _load, + snack: _snack, + ), + const SizedBox(height: 12), + _SecuritySection(email: _profile!.email, snack: _snack), + const SizedBox(height: 12), + _SessionsSection(sessions: _sessions, onChanged: _load, snack: _snack), + const SizedBox(height: 12), + _DangerSection(profile: _profile!, onChanged: _load, snack: _snack), + const SizedBox(height: 24), + ], + ), + ); + } +} + +/// A titled card wrapper matching the web's sectioned layout. +class _Card extends StatelessWidget { + final String title; + final Color? titleColor; + final List children; + const _Card({required this.title, this.titleColor, required this.children}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Theme.of(context).dividerColor), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600, color: titleColor)), + const SizedBox(height: 12), + ...children, + ], + ), + ), + ); + } +} + +// --- Account --------------------------------------------------------------- + +class _AccountSection extends StatefulWidget { + final UserProfile profile; + final TextEditingController nameController; + final Future Function() onChanged; + final void Function(String) snack; + const _AccountSection({ + required this.profile, + required this.nameController, + required this.onChanged, + required this.snack, + }); + @override + State<_AccountSection> createState() => _AccountSectionState(); +} + +class _AccountSectionState extends State<_AccountSection> { + bool _savingName = false; + bool _verifying = false; + + final _oldPw = TextEditingController(); + final _newPw = TextEditingController(); + final _confirmPw = TextEditingController(); + bool _savingPw = false; + String? _pwError; + + @override + void dispose() { + _oldPw.dispose(); + _newPw.dispose(); + _confirmPw.dispose(); + super.dispose(); + } + + Future _saveName() async { + setState(() => _savingName = true); + try { + final updated = await apiClient.updateMe({"name": widget.nameController.text.trim()}); + authService.user = AuthUser( + id: updated.id, + email: updated.email, + name: updated.name, + role: updated.role, + ); + widget.snack("Name saved."); + } catch (e) { + widget.snack("$e"); + } finally { + if (mounted) setState(() => _savingName = false); + } + } + + Future _sendVerification() async { + setState(() => _verifying = true); + try { + await apiClient.requestVerification(); + widget.snack("Verification email requested."); + } catch (e) { + widget.snack("$e"); + } finally { + if (mounted) setState(() => _verifying = false); + } + } + + Future _savePassword() async { + setState(() => _pwError = null); + if (_newPw.text.length < 8) { + setState(() => _pwError = "New password must be at least 8 characters."); + return; + } + if (_newPw.text != _confirmPw.text) { + setState(() => _pwError = "New password and confirmation don't match."); + return; + } + setState(() => _savingPw = true); + try { + await apiClient.changePassword(_oldPw.text, _newPw.text); + _oldPw.clear(); + _newPw.clear(); + _confirmPw.clear(); + widget.snack("Password updated."); + } catch (e) { + setState(() => _pwError = e.toString()); + } finally { + if (mounted) setState(() => _savingPw = false); + } + } + + @override + Widget build(BuildContext context) { + final p = widget.profile; + return _Card( + title: "Account", + children: [ + const Text("Name", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + Row(children: [ + Expanded( + child: TextField( + controller: widget.nameController, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _savingName ? null : _saveName, + child: Text(_savingName ? "Saving…" : "Save"), + ), + ]), + const SizedBox(height: 16), + const Text("Email", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + Row(children: [ + Expanded(child: Text(p.email)), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: p.verified + ? (DriverVault.isDark(context) ? DriverVault.successSoftDark : DriverVault.successSoft) + : (DriverVault.isDark(context) ? DriverVault.warningSoftDark : DriverVault.warningSoft), + borderRadius: BorderRadius.circular(999), + ), + child: Text(p.verified ? "Verified" : "Not verified", + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: p.verified ? DriverVault.success : DriverVault.warning)), + ), + ]), + if (!p.verified) + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _verifying ? null : _sendVerification, + child: Text(_verifying ? "Sending…" : "Resend verification email"), + ), + ), + const Divider(height: 24), + const Text("Change password", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + TextField( + controller: _oldPw, + obscureText: true, + decoration: const InputDecoration( + labelText: "Current password", border: OutlineInputBorder(), isDense: true), + ), + const SizedBox(height: 8), + TextField( + controller: _newPw, + obscureText: true, + decoration: const InputDecoration( + labelText: "New password (min 8)", border: OutlineInputBorder(), isDense: true), + ), + const SizedBox(height: 8), + TextField( + controller: _confirmPw, + obscureText: true, + decoration: const InputDecoration( + labelText: "Confirm new password", border: OutlineInputBorder(), isDense: true), + ), + if (_pwError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(_pwError!, style: const TextStyle(color: DriverVault.danger, fontSize: 13)), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton( + onPressed: _savingPw ? null : _savePassword, + child: Text(_savingPw ? "Updating…" : "Update password"), + ), + ), + ], + ); + } +} + +// --- Appearance ------------------------------------------------------------ + +class _AppearanceSection extends StatefulWidget { + final void Function(String) onError; + const _AppearanceSection({required this.onError}); + @override + State<_AppearanceSection> createState() => _AppearanceSectionState(); +} + +class _AppearanceSectionState extends State<_AppearanceSection> { + Future _save(Map patch) async { + // Optimistic: apply locally first, roll back on failure. + final prev = { + "theme": appSettings.theme, + "locale": appSettings.locale, + "dateFormat": appSettings.dateFormat, + "fontSize": appSettings.fontSize, + }; + appSettings.patch( + theme: patch["theme"], + locale: patch["locale"], + dateFormat: patch["dateFormat"], + fontSize: patch["fontSize"], + ); + setState(() {}); + try { + await apiClient.updateMe(patch); + } catch (e) { + appSettings.patch( + theme: prev["theme"], + locale: prev["locale"], + dateFormat: prev["dateFormat"], + fontSize: prev["fontSize"], + ); + setState(() {}); + widget.onError("$e"); + } + } + + @override + Widget build(BuildContext context) { + return _Card( + title: "Appearance", + children: [ + const Text("Theme", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + SegmentedButton( + segments: const [ + ButtonSegment(value: "light", label: Text("Light")), + ButtonSegment(value: "dark", label: Text("Dark")), + ButtonSegment(value: "system", label: Text("System")), + ], + selected: {appSettings.theme}, + onSelectionChanged: (s) => _save({"theme": s.first}), + ), + const SizedBox(height: 16), + const Text("Language & region", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: appSettings.locale, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: const [ + DropdownMenuItem(value: "en-US", child: Text("English (US)")), + DropdownMenuItem(value: "en-GB", child: Text("English (UK)")), + DropdownMenuItem(value: "pl-PL", child: Text("Polski")), + DropdownMenuItem(value: "de-DE", child: Text("Deutsch")), + DropdownMenuItem(value: "fr-FR", child: Text("Français")), + DropdownMenuItem(value: "es-ES", child: Text("Español")), + ], + onChanged: (v) => v == null ? null : _save({"locale": v}), + ), + const SizedBox(height: 16), + const Text("Date format", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: appSettings.dateFormat, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: const [ + DropdownMenuItem(value: "YMD", child: Text("YYYY-MM-DD")), + DropdownMenuItem(value: "DMY_NUM", child: Text("DD-MM-YYYY")), + DropdownMenuItem(value: "DMY", child: Text("DD Mon YYYY")), + DropdownMenuItem(value: "MDY", child: Text("Mon DD, YYYY")), + ], + onChanged: (v) => v == null ? null : _save({"dateFormat": v}), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text("Example: ${formatDate(DateTime.now())}", + style: const TextStyle(color: Colors.grey, fontSize: 12)), + ), + const SizedBox(height: 16), + const Text("Font size", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + SegmentedButton( + segments: const [ + ButtonSegment(value: "small", label: Text("Small")), + ButtonSegment(value: "medium", label: Text("Medium")), + ButtonSegment(value: "large", label: Text("Large")), + ], + selected: {appSettings.fontSize}, + onSelectionChanged: (s) => _save({"fontSize": s.first}), + ), + ], + ); + } +} + +// --- Profile (avatar + bio) ------------------------------------------------ + +class _ProfileSection extends StatefulWidget { + final UserProfile profile; + final TextEditingController bioController; + final Uint8List? avatar; + final Future Function() onChanged; + final void Function(String) snack; + const _ProfileSection({ + required this.profile, + required this.bioController, + required this.avatar, + required this.onChanged, + required this.snack, + }); + @override + State<_ProfileSection> createState() => _ProfileSectionState(); +} + +class _ProfileSectionState extends State<_ProfileSection> { + bool _avatarBusy = false; + bool _savingBio = false; + + Future _pickAvatar() async { + final picker = ImagePicker(); + final file = await picker.pickImage(source: ImageSource.gallery, maxWidth: 1024); + if (file == null) return; + setState(() => _avatarBusy = true); + try { + final bytes = await file.readAsBytes(); + await apiClient.uploadAvatar(bytes, file.name); + await widget.onChanged(); + } catch (e) { + widget.snack("$e"); + } finally { + if (mounted) setState(() => _avatarBusy = false); + } + } + + Future _removeAvatar() async { + setState(() => _avatarBusy = true); + try { + await apiClient.deleteAvatar(); + await widget.onChanged(); + } catch (e) { + widget.snack("$e"); + } finally { + if (mounted) setState(() => _avatarBusy = false); + } + } + + Future _saveBio() async { + setState(() => _savingBio = true); + try { + await apiClient.updateMe({"bio": widget.bioController.text}); + widget.snack("Bio saved."); + } catch (e) { + widget.snack("$e"); + } finally { + if (mounted) setState(() => _savingBio = false); + } + } + + @override + Widget build(BuildContext context) { + final p = widget.profile; + final initial = (p.name.isNotEmpty ? p.name : p.email).characters.first.toUpperCase(); + return _Card( + title: "Profile", + children: [ + Row(children: [ + CircleAvatar( + radius: 32, + backgroundColor: DriverVault.brandTint(context), + backgroundImage: widget.avatar != null ? MemoryImage(widget.avatar!) : null, + child: widget.avatar == null + ? Text(initial, + style: TextStyle( + fontSize: 22, fontWeight: FontWeight.w700, color: DriverVault.brandOnTint(context))) + : null, + ), + const SizedBox(width: 16), + Wrap(spacing: 8, children: [ + OutlinedButton( + onPressed: _avatarBusy ? null : _pickAvatar, + child: Text(_avatarBusy ? "Working…" : "Upload photo"), + ), + if (p.hasAvatar) + OutlinedButton( + onPressed: _avatarBusy ? null : _removeAvatar, + child: const Text("Remove"), + ), + ]), + ]), + const SizedBox(height: 16), + const Text("Bio", style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + TextField( + controller: widget.bioController, + maxLines: 3, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: "A short note visible to other people in your household.", + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton( + onPressed: _savingBio ? null : _saveBio, + child: Text(_savingBio ? "Saving…" : "Save bio"), + ), + ), + ], + ); + } +} + +// --- Security (biometric sign-in) ------------------------------------------ + +class _SecuritySection extends StatefulWidget { + final String email; + final void Function(String) snack; + const _SecuritySection({required this.email, required this.snack}); + @override + State<_SecuritySection> createState() => _SecuritySectionState(); +} + +class _SecuritySectionState extends State<_SecuritySection> { + bool _available = false; + bool _enabled = false; + bool _hasFace = false; + bool _hasFingerprint = false; + bool _busy = false; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _refresh(); + } + + Future _refresh() async { + final available = await biometricAuth.isAvailable(); + final enabled = await biometricAuth.isEnabled(); + final face = available && await biometricAuth.hasFace(); + final finger = available && await biometricAuth.hasFingerprint(); + if (!mounted) return; + setState(() { + _available = available; + _enabled = enabled; + _hasFace = face; + _hasFingerprint = finger; + _loaded = true; + }); + } + + String get _methodLabel { + if (_hasFace && _hasFingerprint) return "face or fingerprint"; + if (_hasFace) return "face recognition"; + if (_hasFingerprint) return "fingerprint"; + return "biometrics"; + } + + Future _toggle(bool value) async { + if (_busy) return; + if (!value) { + setState(() => _busy = true); + await biometricAuth.disable(); + if (mounted) setState(() { _enabled = false; _busy = false; }); + widget.snack("Biometric sign-in turned off"); + return; + } + // Enabling requires re-confirming the password so we store known-good creds. + final pw = await _promptPassword(); + if (pw == null || pw.isEmpty) return; + setState(() => _busy = true); + try { + // Verify the password (and refresh the session) before storing it. + await authService.login(widget.email, pw); + await biometricAuth.enable(widget.email, pw); + if (mounted) setState(() { _enabled = true; _busy = false; }); + widget.snack("Biometric sign-in enabled"); + } catch (e) { + if (mounted) setState(() => _busy = false); + widget.snack("Could not enable: ${e.toString()}"); + } + } + + Future _promptPassword() { + final ctrl = TextEditingController(); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Confirm your password"), + content: TextField( + controller: ctrl, + obscureText: true, + autofocus: true, + decoration: const InputDecoration( + labelText: "Password", + border: OutlineInputBorder(), + ), + onSubmitted: (v) => Navigator.pop(ctx, v), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text("Cancel")), + FilledButton(onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text("Confirm")), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return _Card( + title: "Security", + children: [ + if (!_loaded) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Text("Checking device…", style: TextStyle(color: Colors.grey)), + ) + else ...[ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text("Biometric sign-in"), + subtitle: Text(_available + ? "Sign in with $_methodLabel instead of your password." + : "No biometrics are enrolled on this device."), + value: _enabled, + onChanged: (!_available || _busy) ? null : _toggle, + ), + ], + ], + ); + } +} + +// --- Privacy & security (sessions) ----------------------------------------- + +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"); + } + } + + @override + Widget build(BuildContext context) { + return _Card( + title: "Privacy & security", + children: [ + const Text( + "Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.", + 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"), + ), + ), + ...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)), + ), + ], + ), + )), + ], + ); + } +} + +// --- Danger zone (account deletion) ---------------------------------------- + +class _DangerSection extends StatefulWidget { + final UserProfile profile; + final Future Function() onChanged; + final void Function(String) snack; + const _DangerSection({required this.profile, required this.onChanged, required this.snack}); + @override + State<_DangerSection> createState() => _DangerSectionState(); +} + +class _DangerSectionState extends State<_DangerSection> { + final _confirmEmail = TextEditingController(); + bool _showConfirm = false; + bool _busy = false; + DateTime? _eligibleAt; + + @override + void dispose() { + _confirmEmail.dispose(); + super.dispose(); + } + + bool get _pending => widget.profile.deletionPending; + DateTime? get _eligible { + if (!_pending) return null; + return _eligibleAt ?? + widget.profile.deletionRequestedAt!.add(const Duration(days: 3)); + } + + bool get _cooldownElapsed => _eligible != null && DateTime.now().isAfter(_eligible!); + + Future _request() async { + if (_confirmEmail.text.trim().toLowerCase() != widget.profile.email.toLowerCase()) { + widget.snack("Type your email to confirm."); + return; + } + setState(() => _busy = true); + try { + _eligibleAt = await apiClient.requestAccountDeletion(_confirmEmail.text.trim()); + _showConfirm = false; + _confirmEmail.clear(); + await widget.onChanged(); + } catch (e) { + widget.snack("$e"); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _cancel() async { + try { + await apiClient.cancelAccountDeletion(); + _eligibleAt = null; + await widget.onChanged(); + } catch (e) { + widget.snack("$e"); + } + } + + Future _finalize() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Delete account?"), + content: const Text("This permanently deletes your account. This cannot be undone."), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text("Cancel")), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), + onPressed: () => Navigator.pop(ctx, true), + child: const Text("Delete"), + ), + ], + ), + ); + if (ok != true) return; + try { + await apiClient.finalizeAccountDeletion(); + await authService.logout(); + } catch (e) { + widget.snack("$e"); + } + } + + @override + Widget build(BuildContext context) { + final p = widget.profile; + return _Card( + title: "Danger zone", + titleColor: DriverVault.danger, + children: [ + if (!_pending) ...[ + const Text( + "Deleting your account removes your login and profile. It does not delete your household's shared cars or service history. There's a 3-day cooldown before deletion is final, and you can cancel any time before then.", + style: TextStyle(fontSize: 13), + ), + const SizedBox(height: 8), + if (!_showConfirm) + OutlinedButton( + onPressed: () => setState(() => _showConfirm = true), + style: OutlinedButton.styleFrom(foregroundColor: DriverVault.danger), + child: const Text("Delete my account"), + ) + else ...[ + Text("Type ${p.email} to confirm", + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + TextField( + controller: _confirmEmail, + decoration: InputDecoration( + hintText: p.email, border: const OutlineInputBorder(), isDense: true), + ), + const SizedBox(height: 8), + Row(children: [ + TextButton( + onPressed: () => setState(() { + _showConfirm = false; + _confirmEmail.clear(); + }), + child: const Text("Cancel"), + ), + const SizedBox(width: 8), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), + onPressed: _busy ? null : _request, + child: Text(_busy ? "Requesting…" : "Request deletion"), + ), + ]), + ], + ] else ...[ + Text( + "Account deletion requested on ${formatDate(p.deletionRequestedAt)}. " + "${_cooldownElapsed ? 'The cooldown has passed. You can now finalize the deletion.' : 'You can still cancel — it becomes permanent after the 3-day cooldown.'}", + style: const TextStyle(fontSize: 13), + ), + const SizedBox(height: 8), + Row(children: [ + OutlinedButton(onPressed: _cancel, child: const Text("Cancel deletion request")), + const SizedBox(width: 8), + if (_cooldownElapsed) + FilledButton( + style: FilledButton.styleFrom(backgroundColor: DriverVault.danger), + onPressed: _finalize, + child: const Text("Permanently delete"), + ), + ]), + ], + ], + ); + } +} diff --git a/Phone App/lib/theme.dart b/Phone App/lib/theme.dart new file mode 100644 index 0000000..dae928a --- /dev/null +++ b/Phone App/lib/theme.dart @@ -0,0 +1,283 @@ +import "package:flutter/material.dart"; +import "package:google_fonts/google_fonts.dart"; + +/// DriverVault design tokens + Material theme, mirroring the web app's design +/// system (blue-led cool palette, Archivo + DM Mono type, 22px cards, soft +/// cool shadows). Every screen styles through this so light/dark theme for free. +class DriverVault { + DriverVault._(); + + // ---- Brand blue ramp (from the DriverVault mark) ---- + static const brand900 = Color(0xFF0B1730); + static const brand800 = Color(0xFF0F1E3D); + static const brand700 = Color(0xFF1E40AF); + static const brand600 = Color(0xFF2563EB); + static const brand500 = Color(0xFF3B82F6); + static const brand400 = Color(0xFF60A5FA); + static const brand300 = Color(0xFF93C5FD); + static const brand100 = Color(0xFFE8F0FD); + + // ---- Cool neutrals ---- + static const ink900 = Color(0xFF0F1E3D); + static const ink600 = Color(0xFF3E4E68); + static const ink500 = Color(0xFF5C6B85); + static const ink400 = Color(0xFF7A8AA6); + static const ink200 = Color(0xFFD6DEEA); + static const ink100 = Color(0xFFE4E9F2); + static const ink50 = Color(0xFFEEF2F8); + static const ink25 = Color(0xFFF7F9FC); + + // ---- Semantic status (car: OK / due / fault) ---- + static const success = Color(0xFF1F8A5B); + static const warning = Color(0xFFD9822B); + static const danger = Color(0xFFDC2A45); + static const info = Color(0xFF2563EB); + + // Light status tints + static const successSoft = Color(0xFFE1F3EA); + static const warningSoft = Color(0xFFFBEDDD); + static const dangerSoft = Color(0xFFFBE3E7); + static const infoSoft = Color(0xFFE8F0FD); + + // Dark status tints (re-cut for dark surfaces) + static const successSoftDark = Color(0xFF12352A); + static const warningSoftDark = Color(0xFF3A2A16); + static const dangerSoftDark = Color(0xFF3A1620); + static const infoSoftDark = Color(0xFF122A4D); + + // ---- Dark surfaces / text / borders ---- + static const darkPage = Color(0xFF0B1730); + static const darkCard = Color(0xFF13233F); + static const darkSunken = Color(0xFF0F1E38); + static const darkBorder = Color(0xFF21324F); + static const darkBorderStrong = Color(0xFF2C3F5E); + static const darkTextStrong = Color(0xFFF2F6FC); + static const darkTextBody = Color(0xFFB7C4D9); + static const darkTextMuted = Color(0xFF7C8CA8); + static const darkAccent = Color(0xFF3B82F6); + + static const radiusControl = 12.0; + static const radiusCard = 22.0; + + static bool isDark(BuildContext c) => Theme.of(c).brightness == Brightness.dark; + + // Brand tint used for avatars / info chips (adapts to theme). + static Color brandTint(BuildContext c) => isDark(c) ? const Color(0xFF17294A) : brand100; + static Color brandOnTint(BuildContext c) => isDark(c) ? brand300 : brand700; + + /// Muted secondary text colour (replaces ad-hoc Colors.grey). + static Color muted(BuildContext c) => isDark(c) ? darkTextMuted : ink400; + + /// DM Mono style for data / units / labels. + static TextStyle mono(BuildContext c, {double size = 13, FontWeight weight = FontWeight.w500, Color? color}) => + GoogleFonts.dmMono( + fontSize: size, + fontWeight: weight, + letterSpacing: 0.2, + color: color ?? Theme.of(c).textTheme.bodyMedium?.color, + ); + + static ThemeData theme(Brightness brightness) { + final dark = brightness == Brightness.dark; + + final scheme = ColorScheme( + brightness: brightness, + primary: dark ? darkAccent : brand600, + onPrimary: Colors.white, + primaryContainer: dark ? const Color(0xFF17294A) : brand100, + onPrimaryContainer: dark ? brand300 : brand700, + secondary: dark ? brand400 : brand700, + onSecondary: Colors.white, + surface: dark ? darkCard : Colors.white, + onSurface: dark ? darkTextStrong : ink900, + surfaceContainerHighest: dark ? darkSunken : ink50, + onSurfaceVariant: dark ? darkTextBody : ink600, + outline: dark ? darkBorderStrong : ink200, + outlineVariant: dark ? darkBorder : ink100, + error: danger, + onError: Colors.white, + errorContainer: dark ? dangerSoftDark : dangerSoft, + onErrorContainer: danger, + ); + + final baseText = dark ? ThemeData.dark().textTheme : ThemeData.light().textTheme; + + return ThemeData( + useMaterial3: true, + brightness: brightness, + colorScheme: scheme, + scaffoldBackgroundColor: dark ? darkPage : ink25, + textTheme: GoogleFonts.archivoTextTheme(baseText).apply( + bodyColor: dark ? darkTextBody : ink600, + displayColor: dark ? darkTextStrong : ink900, + ), + appBarTheme: AppBarTheme( + backgroundColor: dark ? darkCard : Colors.white, + foregroundColor: dark ? darkTextStrong : ink900, + elevation: 0, + scrolledUnderElevation: 0.5, + centerTitle: false, + titleTextStyle: GoogleFonts.archivo( + fontSize: 20, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: dark ? darkTextStrong : ink900, + ), + ), + cardTheme: CardThemeData( + color: dark ? darkCard : Colors.white, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusCard), + side: BorderSide(color: dark ? darkBorder : ink100), + ), + ), + dividerColor: dark ? darkBorder : ink100, + dividerTheme: DividerThemeData(color: dark ? darkBorder : ink100, thickness: 1), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: dark ? darkSunken : Colors.white, + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusControl), + borderSide: BorderSide(color: dark ? darkBorderStrong : ink200), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusControl), + borderSide: BorderSide(color: dark ? darkBorderStrong : ink200), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusControl), + borderSide: BorderSide(color: dark ? darkAccent : brand600, width: 2), + ), + labelStyle: TextStyle(color: dark ? darkTextMuted : ink500), + floatingLabelStyle: TextStyle(color: dark ? darkAccent : brand600), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: dark ? darkAccent : brand600, + foregroundColor: Colors.white, + textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: dark ? darkTextBody : ink600, + side: BorderSide(color: dark ? darkBorderStrong : ink200), + textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: dark ? brand300 : brand700, + textStyle: GoogleFonts.archivo(fontWeight: FontWeight.w600, fontSize: 14), + ), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: dark ? darkAccent : brand600, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusControl)), + ), + chipTheme: ChipThemeData( + backgroundColor: dark ? darkSunken : ink50, + side: BorderSide(color: dark ? darkBorder : ink100), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: dark ? darkCard : Colors.white, + surfaceTintColor: Colors.transparent, + indicatorColor: dark ? const Color(0xFF17294A) : brand100, + elevation: 0, + height: 66, + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final on = states.contains(WidgetState.selected); + return GoogleFonts.archivo( + fontSize: 11, + fontWeight: on ? FontWeight.w600 : FontWeight.w500, + color: on ? (dark ? brand300 : brand700) : (dark ? darkTextMuted : ink400), + ); + }), + iconTheme: WidgetStateProperty.resolveWith((states) { + final on = states.contains(WidgetState.selected); + return IconThemeData( + size: 24, + color: on ? (dark ? brand300 : brand700) : (dark ? darkTextMuted : ink400), + ); + }), + ), + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + ); + } +} + +/// The DriverVault "Fast Forward" mark — three forward-leaning rounded bars of +/// increasing height (skewX(-13°)), reading as acceleration / momentum. +class DriverVaultMark extends StatelessWidget { + final double size; + const DriverVaultMark({super.key, this.size = 32}); + + @override + Widget build(BuildContext context) { + final u = size / 40; // scale factor + Widget bar(double h, Color c) => Container( + width: 5 * u, + height: h * u, + decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(3 * u)), + ); + return SizedBox( + width: size, + height: size, + child: Transform( + alignment: Alignment.center, + transform: Matrix4.skewX(-0.23), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + bar(18, DriverVault.brand700), + SizedBox(width: 3.5 * u), + bar(28, DriverVault.brand500), + SizedBox(width: 3.5 * u), + bar(36, DriverVault.brand400), + ], + ), + ), + ); + } +} + +/// Full DriverVault lockup: the mark + the Archivo italic-800 wordmark +/// ("Driver" in strong ink, "Vault" in brand blue). +class DriverVaultLogo extends StatelessWidget { + final double markSize; + final double fontSize; + const DriverVaultLogo({super.key, this.markSize = 32, this.fontSize = 24}); + + @override + Widget build(BuildContext context) { + final strong = DriverVault.isDark(context) ? DriverVault.darkTextStrong : DriverVault.ink900; + final brand = DriverVault.isDark(context) ? DriverVault.brand400 : DriverVault.brand700; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + DriverVaultMark(size: markSize), + SizedBox(width: markSize * 0.32), + Text.rich( + TextSpan(children: [ + TextSpan(text: "Driver", style: TextStyle(color: strong)), + TextSpan(text: "Vault", style: TextStyle(color: brand)), + ]), + style: GoogleFonts.archivo( + fontSize: fontSize, + fontWeight: FontWeight.w800, + fontStyle: FontStyle.italic, + letterSpacing: -0.5, + ), + ), + ], + ); + } +} diff --git a/Phone App/pubspec.lock b/Phone App/pubspec.lock new file mode 100644 index 0000000..06bbc42 --- /dev/null +++ b/Phone App/pubspec.lock @@ -0,0 +1,754 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52" + url: "https://pub.dev" + source: hosted + version: "4.9.1" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+19" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: "direct main" + description: + name: local_auth_android + sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467 + url: "https://pub.dev" + source: hosted + version: "1.0.56" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49" + url: "https://pub.dev" + source: hosted + version: "1.6.1" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" + url: "https://pub.dev" + source: hosted + version: "2.4.11" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + url: "https://pub.dev" + source: hosted + version: "14.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/Phone App/pubspec.yaml b/Phone App/pubspec.yaml new file mode 100644 index 0000000..3787fdc --- /dev/null +++ b/Phone App/pubspec.yaml @@ -0,0 +1,38 @@ +name: carcontrol_phone +description: "DriverVault — car control & service-tracking phone app." +publish_to: "none" +version: 0.1.0+1 + +environment: + sdk: ">=3.4.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + http: ^1.2.0 + shared_preferences: ^2.2.0 + intl: ^0.19.0 + cupertino_icons: ^1.0.8 + image_picker: ^1.1.2 + local_auth: ^2.3.0 + local_auth_android: ^1.0.46 + flutter_secure_storage: ^9.2.2 + google_fonts: ^6.2.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + flutter_launcher_icons: ^0.14.1 + +flutter: + uses-material-design: true + +# DriverVault launcher icon — regenerate with: +# flutter pub run flutter_launcher_icons +flutter_launcher_icons: + android: true + image_path: "assets/icon/drivervault.png" + min_sdk_android: 21 + adaptive_icon_background: "#1E40AF" + adaptive_icon_foreground: "assets/icon/drivervault-fg.png" diff --git a/Phone App/web/favicon.png b/Phone App/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/Phone App/web/favicon.png differ diff --git a/Phone App/web/icons/Icon-192.png b/Phone App/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/Phone App/web/icons/Icon-192.png differ diff --git a/Phone App/web/icons/Icon-512.png b/Phone App/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/Phone App/web/icons/Icon-512.png differ diff --git a/Phone App/web/icons/Icon-maskable-192.png b/Phone App/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/Phone App/web/icons/Icon-maskable-192.png differ diff --git a/Phone App/web/icons/Icon-maskable-512.png b/Phone App/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/Phone App/web/icons/Icon-maskable-512.png differ diff --git a/Phone App/web/index.html b/Phone App/web/index.html new file mode 100644 index 0000000..23e97bc --- /dev/null +++ b/Phone App/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + carcontrol_phone + + + + + + + diff --git a/Phone App/web/manifest.json b/Phone App/web/manifest.json new file mode 100644 index 0000000..f653f05 --- /dev/null +++ b/Phone App/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "carcontrol_phone", + "short_name": "carcontrol_phone", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..308e818 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Car Control Project + +A car control & service tracking system. Built incrementally — starting with a +**car maintenance tracker** (modeled on `Car Service.xlsx`) and growing toward +live integration with the car via an ESP32 device. + +## Architecture + +All clients communicate with the database **only through the API Server** — +nothing talks to PocketBase directly. + +``` + ┌──────────────────┐ + Web App (Vue) ─────▶│ │ + Phone App (Flutter)▶│ API Server │────▶ PocketBase + Home Assistant ────▶│ (Go, stdlib) │ (10.2.1.10:8027) + ESP32 device ──────▶│ │ + └──────────────────┘ +``` + +| Component | Stack | Status | Docs | +|---|---|---|---| +| **API Server** | Go (stdlib) | ✅ built, running, verified | [API Server/README.md](API%20Server/README.md) | +| **Database** | PocketBase | ✅ running, schema + seed done | — | +| **Web App** | Vue 3 + Vite + Tailwind v4 | ✅ full feature set (below) | [Web App/README.md](Web%20App/README.md) | +| **Phone App** | Flutter (Android) | ✅ web parity + biometric login | [Phone App/README.md](Phone%20App/README.md) | +| **Home Assistant Plugin** | — | ⬜ later | — | +| **Car Agent Device** | ESP32 | ⬜ later | — | + +The Web and Phone apps are at feature parity (the phone omits only data +export/import). + +## Features + +- **Maintenance tracking** — cars, service history (date/odometer + which parts + were changed), and a per-car parts catalog, with next-due date/km status. +- **Accounts & sessions** — JWT login, per-device active sessions with remote + logout, profile + appearance preferences (theme/locale/date/font), email + verification, and account deletion. +- **Per-user ownership & sharing** — each car has an owner and can be shared with + other users as read or write; the UI mirrors the server's access checks. +- **Admin** — role-gated user management (create / role / reset password / delete). +- **Phone biometric login & app lock** — fingerprint / face sign-in with an + app-lock that requires an unlock on relaunch (with a short grace period for + quick app-switches). See the Phone App README. + +## Auth model + +All three apps share one auth model: login via `POST /api/auth/login` returns a +JWT issued by the API Server (after verifying against PocketBase `users`), and +every other endpoint requires `Authorization: Bearer `. Each login also +creates a server-side session whose id is embedded in the token, so sessions can +be listed and revoked. Access to cars/records/parts is gated by per-user +ownership and shares; admin endpoints require the admin role. + +## Domain (from `Car Service.xlsx`) + +- **Cars** — one per vehicle (was: one spreadsheet sheet), with spec fields + (engine / transmission / differential oil, brake fluid, coolant, VIN, …) and + configurable service intervals. +- **Service records** — date + odometer per service, plus which parts were + changed (oil & oil filter, engine air filter, cabin air filter). +- **Parts** — per-car catalog of part numbers. + +Key spreadsheet formulas, reproduced by the API Server on read: + +``` +Next Service Date = service date + serviceIntervalDays (default 365; Excel: =A+365) +Next Service Km = service km + serviceIntervalKm (default 15 000; Excel: =B+15000) +``` + +Intervals are configurable per car. + +## Getting started + +Bring up the stack in this order — each app's README has the details: + +1. **[API Server](API%20Server/README.md)** — configure `.env`, run + `setup-pocketbase.mjs`, start the server. This must be running for either app. +2. **[Web App](Web%20App/README.md)** — `npm install && npm run dev` + (proxies `/api` to the server). +3. **[Phone App](Phone%20App/README.md)** — `flutter build apk` / + `flutter run` with `--dart-define=API_BASE=http://:8080/api`. + +## Layout + +``` +Car Control Project/ +├── API Server/ # Go gateway to PocketBase (the only DB client) +├── Web App/ # Vue 3 + Vite + Tailwind v4 +├── Phone App/ # Flutter (Android) +├── Home Assistant Plugin/ # later phase +└── Car Agent Device/ # ESP32, later phase +``` diff --git a/Web App/.claude/launch.json b/Web App/.claude/launch.json new file mode 100644 index 0000000..193ff6a --- /dev/null +++ b/Web App/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/Web App/.dockerignore b/Web App/.dockerignore new file mode 100644 index 0000000..42661fc --- /dev/null +++ b/Web App/.dockerignore @@ -0,0 +1,15 @@ +# Keep the build context small; node_modules and dist are rebuilt in the image. +node_modules/ +dist/ +dist-ssr/ + +# Logs and local files +*.log +*.local + +# VCS / editor noise +.git/ +.gitignore +.vscode/ +.idea/ +.DS_Store diff --git a/Web App/.gitignore b/Web App/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/Web App/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/Web App/Dockerfile b/Web App/Dockerfile new file mode 100644 index 0000000..ccf6505 --- /dev/null +++ b/Web App/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +# --- Build stage ------------------------------------------------------------- +# Build the Vue 3 + Vite SPA into static assets. +FROM node:22-alpine AS build + +WORKDIR /app + +# Install dependencies against the lockfile for reproducible builds. +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +# VITE_API_BASE is baked into the bundle at build time. Leave empty to use the +# same-origin "/api" (nginx proxies it to the API Server — see nginx.conf). +ARG VITE_API_BASE +RUN npm run build + +# --- Runtime stage ----------------------------------------------------------- +# Serve the built assets with nginx (Alpine-based) and proxy /api upstream. +FROM nginx:alpine + +# nginx substitutes ${API_TARGET} into this template at container start +# (only env-defined vars are replaced, so $uri/$host are left intact). +COPY nginx.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/dist /usr/share/nginx/html + +# Upstream API Server; override at runtime (compose/`docker run -e`). +ENV API_TARGET=http://api-server:8080 + +EXPOSE 80 +# The default nginx entrypoint renders templates then launches nginx. diff --git a/Web App/README.md b/Web App/README.md new file mode 100644 index 0000000..c75227a --- /dev/null +++ b/Web App/README.md @@ -0,0 +1,75 @@ +# DriverVault — Web App + +Vue 3 + Vite + Tailwind CSS v4 maintenance tracker. Talks **only** to the API +Server (never to PocketBase directly). Built from `Car Service.xlsx`. Plain JS +(not TS). Vue calls the central API Server directly — there is no separate web +backend. + +## Run + +```powershell +npm install +npm run dev # http://localhost:5173 +``` + +The dev server proxies `/api/*` to the API Server (default `http://localhost:8080`, +override with `VITE_API_TARGET`), so the client uses same-origin relative URLs and +avoids CORS. The **API Server must be running** — see `../API Server/README.md`. +The dev server also listens on all interfaces (`host: true`) so it's reachable on +the LAN (e.g. `http://10.2.1.101:5173`). + +At runtime, users can override the API base URL from the login screen's **Server +settings** (persisted in `localStorage` as `cc_server_url`); resolution order is +that override → `VITE_API_BASE` → `/api`. + +## Features + +- **Dashboard** — one card per car: last service, odometer, next-due date/km, and + a status badge (OK / due soon ≤30d / overdue) from the Excel formulas. Add a + car; shared cars are labelled and gated by your access level. +- **Car detail** — full service history (date, km, computed next date/km, and the + changed-parts flags) plus the per-car parts catalog and all car spec fields + (engine / transmission / differential oil, brake fluid, coolant, VIN, …). + Add/edit/delete service records, parts, and the car; **share** the car with + other users (read/write, owner only). Edit/delete controls are hidden for + read-only shares. +- **Settings** — account (name / email verification / password), appearance + (theme light/dark/system, locale, date format, font size), profile (avatar, + bio), data **export/import**, active sessions with remote logout, and the + account-deletion state machine. +- **Admin** — `/admin` user management (list / create / role / reset password / + delete), gated by the admin role via a router guard + nav link. +- **Theming** — light/dark/system app-wide (Tailwind v4 class strategy); `prefs.js` + toggles `.dark` on `` and applies the saved theme/locale/date/font. + +## Auth & access + +Login gets a JWT from the API Server (stored client-side) and creates a server +session. `auth.js` exposes `isAdmin` and the current profile; the router guards +`public` / `admin` routes. Cars are per-user (owned + shared), and the UI mirrors +the server's read / write / owner access levels. + +## Structure + +``` +src/ +├── main.js # app bootstrap +├── router.js # /login, / (dashboard), /cars/:id, /settings, /admin +├── api.js # the only place that calls the API Server (base URL resolution) +├── auth.js # session/profile state, isAdmin +├── prefs.js # theme/locale/date/font preferences → +├── lib/format.js # date/km formatting + next-service status badges +├── style.css # Tailwind v4 entry (+ dark custom-variant) +├── App.vue # layout shell + nav (Admin link when admin) +├── components/ +│ ├── Modal.vue CarFormModal.vue ServiceFormModal.vue PartFormModal.vue ShareModal.vue +└── views/ + ├── Login.vue Dashboard.vue CarDetail.vue + └── Settings.vue AdminUsers.vue +``` + +## Build + +```powershell +npm run build # -> dist/ +``` diff --git a/Web App/docker-compose.yml b/Web App/docker-compose.yml new file mode 100644 index 0000000..8df4b89 --- /dev/null +++ b/Web App/docker-compose.yml @@ -0,0 +1,18 @@ +services: + web-app: + build: + context: . + dockerfile: Dockerfile + args: + # Baked into the bundle at build time. Leave empty to use same-origin + # "/api", which nginx proxies to API_TARGET below. + VITE_API_BASE: "${VITE_API_BASE:-}" + image: drivervault-web + container_name: drivervault-web + restart: unless-stopped + ports: + - "${WEB_PORT:-8081}:80" + environment: + # Upstream API Server that nginx proxies /api/ to. Point this at your + # external API Server (host:port), e.g. http://10.2.1.10:8080. + API_TARGET: "${API_TARGET:-http://api-server:8080}" diff --git a/Web App/index.html b/Web App/index.html new file mode 100644 index 0000000..3462d99 --- /dev/null +++ b/Web App/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + DriverVault + + +
+ + + diff --git a/Web App/nginx.conf.template b/Web App/nginx.conf.template new file mode 100644 index 0000000..6366697 --- /dev/null +++ b/Web App/nginx.conf.template @@ -0,0 +1,35 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Compress text assets. + gzip on; + gzip_types text/plain text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + # Proxy API calls to the API Server. The /api prefix is preserved because + # ${API_TARGET} has no path, so /api/me -> $API_TARGET/api/me. + location /api/ { + proxy_pass ${API_TARGET}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Cache fingerprinted build assets aggressively. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # SPA fallback: unknown routes return index.html so the Vue router handles them. + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/Web App/package-lock.json b/Web App/package-lock.json new file mode 100644 index 0000000..11ce6ad --- /dev/null +++ b/Web App/package-lock.json @@ -0,0 +1,1501 @@ +{ + "name": "web-app", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web-app", + "version": "0.0.0", + "dependencies": { + "vue": "^3.5.39", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@vitejs/plugin-vue": "^6.0.7", + "tailwindcss": "^4.3.2", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/Web App/package.json b/Web App/package.json new file mode 100644 index 0000000..4012aa5 --- /dev/null +++ b/Web App/package.json @@ -0,0 +1,21 @@ +{ + "name": "drivervault-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@vitejs/plugin-vue": "^6.0.7", + "tailwindcss": "^4.3.2", + "vite": "^8.1.1" + }, + "dependencies": { + "vue": "^3.5.39", + "vue-router": "^4.6.4" + } +} diff --git a/Web App/public/apple-touch-icon.png b/Web App/public/apple-touch-icon.png new file mode 100644 index 0000000..09d559f Binary files /dev/null and b/Web App/public/apple-touch-icon.png differ diff --git a/Web App/public/brand/drivervault-appicon-256.png b/Web App/public/brand/drivervault-appicon-256.png new file mode 100644 index 0000000..09d559f Binary files /dev/null and b/Web App/public/brand/drivervault-appicon-256.png differ diff --git a/Web App/public/brand/drivervault-appicon.svg b/Web App/public/brand/drivervault-appicon.svg new file mode 100644 index 0000000..079bcb7 --- /dev/null +++ b/Web App/public/brand/drivervault-appicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Web App/public/brand/drivervault-icon-mono.svg b/Web App/public/brand/drivervault-icon-mono.svg new file mode 100644 index 0000000..03e1008 --- /dev/null +++ b/Web App/public/brand/drivervault-icon-mono.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Web App/public/brand/drivervault-icon-white.svg b/Web App/public/brand/drivervault-icon-white.svg new file mode 100644 index 0000000..3d72179 --- /dev/null +++ b/Web App/public/brand/drivervault-icon-white.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Web App/public/brand/drivervault-icon.svg b/Web App/public/brand/drivervault-icon.svg new file mode 100644 index 0000000..bc05057 --- /dev/null +++ b/Web App/public/brand/drivervault-icon.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Web App/public/brand/drivervault-lockup-dark.svg b/Web App/public/brand/drivervault-lockup-dark.svg new file mode 100644 index 0000000..4cba184 --- /dev/null +++ b/Web App/public/brand/drivervault-lockup-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + DriverVault + \ No newline at end of file diff --git a/Web App/public/brand/drivervault-lockup.svg b/Web App/public/brand/drivervault-lockup.svg new file mode 100644 index 0000000..6e7fd3a --- /dev/null +++ b/Web App/public/brand/drivervault-lockup.svg @@ -0,0 +1,10 @@ + + + + + + + + + DriverVault + \ No newline at end of file diff --git a/Web App/public/favicon-16.png b/Web App/public/favicon-16.png new file mode 100644 index 0000000..c1a0c58 Binary files /dev/null and b/Web App/public/favicon-16.png differ diff --git a/Web App/public/favicon-32.png b/Web App/public/favicon-32.png new file mode 100644 index 0000000..f95daa7 Binary files /dev/null and b/Web App/public/favicon-32.png differ diff --git a/Web App/public/favicon.svg b/Web App/public/favicon.svg new file mode 100644 index 0000000..079bcb7 --- /dev/null +++ b/Web App/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Web App/src/App.vue b/Web App/src/App.vue new file mode 100644 index 0000000..f131472 --- /dev/null +++ b/Web App/src/App.vue @@ -0,0 +1,126 @@ + + + diff --git a/Web App/src/api.js b/Web App/src/api.js new file mode 100644 index 0000000..7dc161b --- /dev/null +++ b/Web App/src/api.js @@ -0,0 +1,149 @@ +// Single client for the Car Control API Server. The web app never talks to +// PocketBase directly — only to these endpoints (proxied to the API Server in +// dev via vite.config.js). + +// Default API base: the Vite env override, else the same-origin "/api" (proxied +// to the API Server in dev). A user can override this at runtime via the login +// screen's "Server settings" — stored in localStorage and used for every call. +export const DEFAULT_API_BASE = import.meta.env.VITE_API_BASE || "/api"; +const SERVER_KEY = "cc_server_url"; + +// Resolved fresh on each request so changing it takes effect without a reload. +function apiBase() { + return localStorage.getItem(SERVER_KEY) || DEFAULT_API_BASE; +} + +export function getServerUrl() { + return localStorage.getItem(SERVER_KEY) || ""; +} + +// Persist a custom API base URL (trailing slashes trimmed). Empty/blank clears +// the override, falling back to the default. +export function setServerUrl(url) { + const trimmed = (url || "").trim().replace(/\/+$/, ""); + if (trimmed) localStorage.setItem(SERVER_KEY, trimmed); + else localStorage.removeItem(SERVER_KEY); +} + +export const TOKEN_KEY = "cc_token"; +export const USER_KEY = "cc_user"; + +function authHeader() { + const t = localStorage.getItem(TOKEN_KEY); + return t ? { Authorization: "Bearer " + t } : {}; +} + +async function handleResponse(res, path) { + // An expired/invalid token on any non-login call ends the session. + if (res.status === 401 && path !== "/auth/login") { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + if (location.pathname !== "/login") location.href = "/login"; + throw new Error("Session expired — please log in again."); + } + + 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); + } + return data; +} + +async function request(path, options = {}) { + const res = await fetch(apiBase() + path, { + headers: { "Content-Type": "application/json", ...authHeader(), ...(options.headers || {}) }, + ...options, + }); + return handleResponse(res, path); +} + +// Like request(), but for multipart/form-data bodies (file uploads) — the +// browser sets its own Content-Type (with boundary), so we must not. +async function requestForm(path, options = {}) { + const res = await fetch(apiBase() + path, { headers: { ...authHeader() }, ...options }); + return handleResponse(res, path); +} + +// Fetches a binary response (image, export file) as a Blob, since it needs the +// Authorization header — a plain or can't attach one. +async function requestBlob(path) { + const res = await fetch(apiBase() + path, { headers: authHeader() }); + if (res.status === 401) return handleResponse(res, path); + if (!res.ok) throw new Error("Request failed: " + res.statusText); + const filename = (res.headers.get("Content-Disposition") || "").match(/filename="([^"]+)"/)?.[1]; + return { blob: await res.blob(), filename }; +} + +export const api = { + // Auth + login: (email, password) => + request("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }), + me: () => request("/auth/me"), + + // Cars + listCars: () => request("/cars"), + getCar: (id) => request(`/cars/${id}`), + createCar: (body) => request("/cars", { method: "POST", body: JSON.stringify(body) }), + updateCar: (id, body) => request(`/cars/${id}`, { method: "PATCH", body: JSON.stringify(body) }), + deleteCar: (id) => request(`/cars/${id}`, { method: "DELETE" }), + + // Sharing (owner-only). A share grants another user read or write access. + listCarShares: (carId) => request(`/cars/${carId}/shares`), + addCarShare: (carId, email, permission) => + request(`/cars/${carId}/shares`, { method: "POST", body: JSON.stringify({ email, permission }) }), + removeCarShare: (carId, userId) => + request(`/cars/${carId}/shares/${userId}`, { method: "DELETE" }), + + // Service records + listCarServices: (carId) => request(`/cars/${carId}/service-records`), + createService: (body) => + request("/service-records", { method: "POST", body: JSON.stringify(body) }), + updateService: (id, body) => + request(`/service-records/${id}`, { method: "PATCH", body: JSON.stringify(body) }), + deleteService: (id) => request(`/service-records/${id}`, { method: "DELETE" }), + + // Parts + listCarParts: (carId) => request(`/cars/${carId}/parts`), + createPart: (body) => request("/parts", { method: "POST", body: JSON.stringify(body) }), + updatePart: (id, body) => + 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" }), + + // Settings — account/profile/appearance + getMe: () => request("/me"), + updateMe: (body) => request("/me", { method: "PATCH", body: JSON.stringify(body) }), + changePassword: (oldPassword, newPassword) => + request("/me/password", { method: "POST", body: JSON.stringify({ oldPassword, newPassword }) }), + requestVerification: () => request("/me/verify/request", { method: "POST" }), + uploadAvatar: (file) => { + const form = new FormData(); + form.append("avatar", file); + return requestForm("/me/avatar", { method: "POST", body: form }); + }, + 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) }), + requestAccountDeletion: (confirmEmail) => + request("/me/delete", { method: "POST", body: JSON.stringify({ confirmEmail }) }), + cancelAccountDeletion: () => request("/me/delete/cancel", { method: "POST" }), + finalizeAccountDeletion: () => request("/me", { method: "DELETE" }), +}; diff --git a/Web App/src/auth.js b/Web App/src/auth.js new file mode 100644 index 0000000..c895f89 --- /dev/null +++ b/Web App/src/auth.js @@ -0,0 +1,51 @@ +// Reactive auth state shared across the app. Token + user are persisted to +// localStorage so a refresh keeps the session. The actual network calls live in +// api.js (which reads the token from localStorage on each request). +import { reactive, computed } from "vue"; +import { api, TOKEN_KEY, USER_KEY } from "./api"; +import { applyProfilePrefs } from "./prefs"; + +export const state = reactive({ + token: localStorage.getItem(TOKEN_KEY) || "", + user: JSON.parse(localStorage.getItem(USER_KEY) || "null"), + // Full settings-panel profile (bio, theme, locale, ...), fetched separately + // from /api/me since the login response only carries id/email/name. + profile: null, +}); + +export const isAuthenticated = computed(() => !!state.token); + +// 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" +); + +export async function login(email, password) { + const res = await api.login(email, password); + state.token = res.token; + state.user = res.user; + localStorage.setItem(TOKEN_KEY, res.token); + localStorage.setItem(USER_KEY, JSON.stringify(res.user)); + await refreshProfile(); + return res; +} + +export function logout() { + state.token = ""; + state.user = null; + state.profile = null; + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); +} + +// Fetches the full profile (used for Settings + to apply appearance prefs). +// Safe to call on app boot when a token already exists from a previous visit. +export async function refreshProfile() { + if (!state.token) return null; + const profile = await api.getMe(); + state.profile = profile; + applyProfilePrefs(profile); + return profile; +} diff --git a/Web App/src/components/CarFormModal.vue b/Web App/src/components/CarFormModal.vue new file mode 100644 index 0000000..a3aee0f --- /dev/null +++ b/Web App/src/components/CarFormModal.vue @@ -0,0 +1,173 @@ + + + diff --git a/Web App/src/components/Logo.vue b/Web App/src/components/Logo.vue new file mode 100644 index 0000000..05bd0e6 --- /dev/null +++ b/Web App/src/components/Logo.vue @@ -0,0 +1,29 @@ + + + diff --git a/Web App/src/components/Modal.vue b/Web App/src/components/Modal.vue new file mode 100644 index 0000000..f524f68 --- /dev/null +++ b/Web App/src/components/Modal.vue @@ -0,0 +1,13 @@ + + + diff --git a/Web App/src/components/PartFormModal.vue b/Web App/src/components/PartFormModal.vue new file mode 100644 index 0000000..d68b0f6 --- /dev/null +++ b/Web App/src/components/PartFormModal.vue @@ -0,0 +1,63 @@ + + + diff --git a/Web App/src/components/ServiceFormModal.vue b/Web App/src/components/ServiceFormModal.vue new file mode 100644 index 0000000..dcb786d --- /dev/null +++ b/Web App/src/components/ServiceFormModal.vue @@ -0,0 +1,91 @@ + + + diff --git a/Web App/src/components/ShareModal.vue b/Web App/src/components/ShareModal.vue new file mode 100644 index 0000000..8151c13 --- /dev/null +++ b/Web App/src/components/ShareModal.vue @@ -0,0 +1,118 @@ + + + diff --git a/Web App/src/lib/format.js b/Web App/src/lib/format.js new file mode 100644 index 0000000..cae7d16 --- /dev/null +++ b/Web App/src/lib/format.js @@ -0,0 +1,94 @@ +// Formatting + maintenance-status helpers. The status logic follows the +// spreadsheet idea: a service is "due" when its computed next-service date +// (service date + interval) approaches/passes today, OR when the car's current +// odometer approaches/passes the computed next-service km. + +import { prefs } from "../prefs.js"; + +export function formatDate(value) { + if (!value) return "—"; + const d = new Date(value); + if (isNaN(d)) return "—"; + + const day = String(d.getDate()).padStart(2, "0"); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const monthName = d.toLocaleDateString(prefs.locale || undefined, { month: "short" }); + const year = d.getFullYear(); + + switch (prefs.dateFormat) { + case "DMY_NUM": + return `${day}-${month}-${year}`; + case "DMY": + return `${day} ${monthName} ${year}`; + case "MDY": + return `${monthName} ${day}, ${year}`; + case "YMD": + default: + return `${year}-${month}-${day}`; + } +} + +export function formatKm(value) { + if (value == null || value === "" || value === 0) return "—"; + return Number(value).toLocaleString() + " km"; +} + +const DAY = 24 * 60 * 60 * 1000; +const KM_SOON = 1000; // within 1000 km of due => "soon" + +// daysUntil returns whole days from today to the given date (negative = past). +export function daysUntil(value) { + if (!value) return null; + const target = new Date(value); + if (isNaN(target)) return null; + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.round((target - today) / DAY); +} + +// Severity ranking so we can pick the worst of the date/km signals. +const RANK = { unknown: 0, ok: 1, soon: 2, overdue: 3 }; +// DriverVault status language: On track (green) / Due soon (amber) / Action +// needed (red). Uses the shared badge recipes from style.css so tints flip in +// dark mode automatically. +const STYLE = { + unknown: "dh-badge dh-badge-neutral", + ok: "dh-badge dh-badge-success", + soon: "dh-badge dh-badge-warning", + overdue: "dh-badge dh-badge-danger", +}; + +// dateSignal classifies the next-due date relative to today. +function dateSignal(nextServiceDate) { + const days = daysUntil(nextServiceDate); + if (days == null) return { key: "unknown", label: "No data" }; + if (days < 0) return { key: "overdue", label: `Service Overdue ${Math.abs(days)}d` }; + if (days <= 30) return { key: "soon", label: `Due in ${days}d` }; + return { key: "ok", label: `OK · ${days}d` }; +} + +// kmSignal classifies the current odometer against the next-due km. +function kmSignal(currentKm, nextServiceKm) { + if (!currentKm || !nextServiceKm) return { key: "unknown", label: "No km" }; + const remaining = nextServiceKm - currentKm; + if (remaining < 0) return { key: "overdue", label: `Service Overdue ${Math.abs(remaining).toLocaleString()} km` }; + if (remaining <= KM_SOON) return { key: "soon", label: `In ${remaining.toLocaleString()} km` }; + return { key: "ok", label: `${remaining.toLocaleString()} km left` }; +} + +// serviceStatus combines the date- and km-based signals, returning the worse of +// the two for the badge. `latest` is the most recent service record (with +// nextServiceDate/nextServiceKm); `car` carries the current odometer. +export function serviceStatus(latest, car = null) { + const date = dateSignal(latest?.nextServiceDate); + const km = kmSignal(car?.currentKm, latest?.nextServiceKm); + + const worse = RANK[km.key] > RANK[date.key] ? km : date; + // If only one signal has data, use that one's label. + let label = worse.label; + if (date.key === "unknown" && km.key !== "unknown") label = km.label; + else if (km.key === "unknown" && date.key !== "unknown") label = date.label; + + return { key: worse.key, label, classes: STYLE[worse.key], date, km }; +} diff --git a/Web App/src/main.js b/Web App/src/main.js new file mode 100644 index 0000000..0f2b268 --- /dev/null +++ b/Web App/src/main.js @@ -0,0 +1,8 @@ +import { createApp } from "vue"; +import "./style.css"; +import App from "./App.vue"; +import router from "./router"; +import { initPrefs } from "./prefs"; + +initPrefs(); +createApp(App).use(router).mount("#app"); diff --git a/Web App/src/prefs.js b/Web App/src/prefs.js new file mode 100644 index 0000000..7370223 --- /dev/null +++ b/Web App/src/prefs.js @@ -0,0 +1,47 @@ +// Appearance preferences (theme / locale / date format / font size), applied +// to the document so every view — not just the Settings page — reflects them. +import { reactive } from "vue"; + +export const prefs = reactive({ + theme: "system", // light | dark | system + locale: "en-US", + dateFormat: "YMD", // YMD | DMY | MDY + fontSize: "medium", // small | medium | large +}); + +const FONT_SCALE = { small: "93.75%", medium: "100%", large: "112.5%" }; + +let media = null; + +function applyTheme() { + const dark = prefs.theme === "dark" || (prefs.theme === "system" && media?.matches); + document.documentElement.classList.toggle("dark", !!dark); +} + +function applyFontSize() { + document.documentElement.style.fontSize = FONT_SCALE[prefs.fontSize] || FONT_SCALE.medium; +} + +// Called once at startup so the login screen (pre-auth) already respects the +// system color scheme, before any profile has been loaded. +export function initPrefs() { + if (!media) { + media = window.matchMedia("(prefers-color-scheme: dark)"); + media.addEventListener("change", () => { + if (prefs.theme === "system") applyTheme(); + }); + } + applyTheme(); + applyFontSize(); +} + +// Called after fetching /api/me (login, app boot, and Settings saves) to sync +// the document to the signed-in user's saved preferences. +export function applyProfilePrefs(profile) { + prefs.theme = profile.theme || "system"; + prefs.locale = profile.locale || "en-US"; + prefs.dateFormat = profile.dateFormat || "YMD"; + prefs.fontSize = profile.fontSize || "medium"; + applyTheme(); + applyFontSize(); +} diff --git a/Web App/src/router.js b/Web App/src/router.js new file mode 100644 index 0000000..086d11c --- /dev/null +++ b/Web App/src/router.js @@ -0,0 +1,37 @@ +import { createRouter, createWebHistory } from "vue-router"; +import Dashboard from "./views/Dashboard.vue"; +import CarDetail from "./views/CarDetail.vue"; +import Login from "./views/Login.vue"; +import Settings from "./views/Settings.vue"; +import AdminUsers from "./views/AdminUsers.vue"; +import { isAuthenticated, isAdmin } from "./auth"; + +const routes = [ + { path: "/login", name: "login", component: Login, meta: { public: true } }, + { path: "/", name: "dashboard", component: Dashboard }, + { path: "/cars/:id", name: "car", component: CarDetail, props: true }, + { path: "/settings", name: "settings", component: Settings }, + { path: "/admin", name: "admin", component: AdminUsers, meta: { admin: true } }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +// Guard: protected routes require a token; visiting /login while authed bounces home. +router.beforeEach((to) => { + if (!to.meta.public && !isAuthenticated.value) { + return { name: "login", query: { redirect: to.fullPath } }; + } + if (to.name === "login" && isAuthenticated.value) { + return { name: "dashboard" }; + } + // Admin-only routes: bounce non-admins to the dashboard. (Server enforces the + // real gate; this just avoids showing a page that would 403 on every call.) + if (to.meta.admin && !isAdmin.value) { + return { name: "dashboard" }; + } +}); + +export default router; diff --git a/Web App/src/style.css b/Web App/src/style.css new file mode 100644 index 0000000..3416878 --- /dev/null +++ b/Web App/src/style.css @@ -0,0 +1,360 @@ +@import "tailwindcss"; + +/* DriverVault webfonts — Archivo (UI + display, italic 800 is the brand voice) + DM Mono (data/labels). */ +@import url("https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;0,700;0,800;1,600;1,700;1,800&family=DM+Mono:ital,wght@0,400;0,500;1,400&display=swap"); + +/* Tailwind v4 defaults dark: to a prefers-color-scheme media query. We need + theme to be user-selectable (light/dark/system), so switch it to a class + strategy — prefs.js toggles .dark on based on the saved preference. */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* ============================================================================ + DriverVault design tokens (from the design system). Raw ramps + semantic + aliases live here as CSS custom properties; .dark re-points the aliases so + any component styled with the aliases themes for free. + ========================================================================== */ +:root { + color-scheme: light; + + /* ---- Brand blue ramp (from the DriverVault mark) ---- */ + --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; + + /* ---- Cool neutrals ---- */ + --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: #ffffff; + + /* ---- Semantic (car status: OK / due / fault) ---- */ + --success-600: #1f8a5b; + --success-100: #e1f3ea; + --warning-600: #d9822b; + --warning-100: #fbeddd; + --danger-600: #dc2a45; + --danger-100: #fbe3e7; + --info-600: #2563eb; + --info-100: #e8f0fd; + + /* ---- Semantic aliases ---- */ + --surface-page: var(--ink-25); + --surface-card: var(--white); + --surface-sunken: var(--ink-50); + --surface-inverse: var(--brand-900); + + --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-inverse: var(--white); + --text-brand: var(--brand-700); + + --accent: var(--brand-600); + --accent-hover: var(--brand-700); + --accent-press: var(--brand-800); + --focus-ring: var(--brand-400); + + /* ---- Elevation (soft, cool, low-spread) ---- */ + --shadow-xs: 0 1px 2px rgba(15, 30, 61, 0.06); + --shadow-sm: 0 1px 2px rgba(15, 30, 61, 0.04), 0 2px 6px rgba(15, 30, 61, 0.06); + --shadow-md: 0 1px 2px rgba(15, 30, 61, 0.04), 0 12px 30px -12px rgba(15, 30, 61, 0.14); + --shadow-lg: 0 1px 2px rgba(15, 30, 61, 0.04), 0 24px 60px -28px rgba(15, 30, 61, 0.22); + --shadow-focus: 0 0 0 3px rgba(96, 165, 250, 0.45); + + /* ---- Type ---- */ + --font-display: "Archivo", system-ui, sans-serif; + --font-sans: "Archivo", system-ui, sans-serif; + --font-mono: "DM Mono", ui-monospace, "SF Mono", monospace; +} + +/* Dark theme — prefs.js toggles .dark on . Re-points semantic aliases + + the raw ramp steps a few surfaces read directly (from the DS dark.css). */ +html.dark { + color-scheme: dark; + + /* Raw ramp steps used directly by some surfaces (tracks, tints, washes) */ + --ink-25: #0b1730; + --ink-50: #132441; + --ink-100: #1b2c49; + --ink-200: #26395a; + --ink-300: #3c5173; + --brand-100: #17294a; + + /* Semantic status tints, re-cut for dark surfaces */ + --success-100: #12352a; + --warning-100: #3a2a16; + --danger-100: #3a1620; + --info-100: #122a4d; + + --surface-page: #0b1730; + --surface-card: #13233f; + --surface-sunken: #0f1e38; + --surface-inverse: #f2f6fc; + + --border-subtle: #21324f; + --border-default: #2c3f5e; + --border-strong: #3c5173; + + --text-strong: #f2f6fc; + --text-body: #b7c4d9; + --text-muted: #7c8ca8; + --text-inverse: #0b1730; + --text-brand: #93c5fd; + + --accent: #3b82f6; + --accent-hover: #60a5fa; + --accent-press: #2563eb; + --focus-ring: #60a5fa; + + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.4); + --shadow-md: 0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 30px -12px rgba(0, 0, 0, 0.55); + --shadow-lg: 0 1px 2px rgba(0, 0, 0, 0.4), 0 24px 60px -28px rgba(0, 0, 0, 0.65); + --shadow-focus: 0 0 0 3px rgba(96, 165, 250, 0.5); +} + +/* ============================================================================ + Map DriverVault tokens into Tailwind's theme so semantic utilities exist: + bg-card, bg-page, text-strong, text-muted, border-subtle, bg-brand-600, + text-success, bg-warning-soft, shadow-card, rounded-card, font-mono, etc. + `inline` keeps the var() reference in generated utilities so they flip in + dark mode automatically (no dark: variant needed for semantic colors). + ========================================================================== */ +@theme inline { + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + + /* Brand ramp */ + --color-brand-100: var(--brand-100); + --color-brand-200: var(--brand-200); + --color-brand-300: var(--brand-300); + --color-brand-400: var(--brand-400); + --color-brand-500: var(--brand-500); + --color-brand-600: var(--brand-600); + --color-brand-700: var(--brand-700); + --color-brand-800: var(--brand-800); + --color-brand-900: var(--brand-900); + + /* Surfaces */ + --color-page: var(--surface-page); + --color-card: var(--surface-card); + --color-sunken: var(--surface-sunken); + --color-inverse: var(--surface-inverse); + + /* Text */ + --color-strong: var(--text-strong); + --color-body: var(--text-body); + --color-muted: var(--text-muted); + --color-brandtext: var(--text-brand); + --color-oninverse: var(--text-inverse); + + /* Borders (color tokens — used as border-subtle/default/strong) */ + --color-subtle: var(--border-subtle); + --color-default: var(--border-default); + --color-strongline: var(--border-strong); + + /* Accent + focus */ + --color-accent: var(--accent); + --color-accent-hover: var(--accent-hover); + --color-accent-press: var(--accent-press); + --color-focus: var(--focus-ring); + + /* Status — solid (600) + soft tint (100) */ + --color-success: var(--success-600); + --color-success-soft: var(--success-100); + --color-warning: var(--warning-600); + --color-warning-soft: var(--warning-100); + --color-danger: var(--danger-600); + --color-danger-soft: var(--danger-100); + --color-info: var(--info-600); + --color-info-soft: var(--info-100); + + /* Radius */ + --radius-control: 12px; + --radius-card: 22px; + --radius-pill: 999px; + + /* Elevation */ + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow-card: var(--shadow-md); + --shadow-pop: var(--shadow-lg); +} + +html, +body, +#app { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font-sans); + background: var(--surface-page); + color: var(--text-body); + -webkit-font-smoothing: antialiased; +} + +/* Small mono eyebrow labels / data units (RANGE, TIRE PSI, LAST SERVICE). */ +.eyebrow { + font-family: var(--font-mono); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 11px; + color: var(--text-muted); +} + +/* Monospaced data (numbers, units, VINs) so columns align. */ +.data { + font-family: var(--font-mono); + letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; +} + +/* ============================================================================ + DriverVault component primitives — shared class recipes so every view stays on + brand without repeating long utility strings. Styled via semantic tokens so + they flip in dark mode automatically. + ========================================================================== */ +@layer components { + /* Cards: border + soft shadow together, generous radius. */ + .dh-card { + background: var(--surface-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-card); + box-shadow: var(--shadow-md); + } + + /* Buttons — shared shape/motion. */ + .dh-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: var(--radius-control); + padding: 0.5rem 1rem; + font-weight: 600; + font-size: 0.875rem; + line-height: 1.25rem; + transition: background-color 0.15s cubic-bezier(0.2, 0.7, 0.2, 1), + color 0.15s, border-color 0.15s, transform 0.15s, box-shadow 0.15s; + cursor: pointer; + white-space: nowrap; + } + .dh-btn:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); + } + .dh-btn:active { + transform: scale(0.98); + } + .dh-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + box-shadow: none; + } + + .dh-btn-primary { + background: var(--accent); + color: #fff; + } + .dh-btn-primary:hover:not(:disabled) { + background: var(--accent-hover); + } + .dh-btn-primary:active:not(:disabled) { + background: var(--accent-press); + } + + .dh-btn-ghost { + background: transparent; + color: var(--text-body); + border: 1px solid var(--border-subtle); + } + .dh-btn-ghost:hover:not(:disabled) { + background: var(--surface-sunken); + color: var(--text-strong); + } + + .dh-btn-danger { + background: var(--danger-600); + color: #fff; + } + .dh-btn-danger:hover:not(:disabled) { + filter: brightness(0.94); + } + + /* Text/number fields + selects. */ + .dh-input { + width: 100%; + background: var(--surface-card); + color: var(--text-strong); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.25rem; + transition: border-color 0.15s, box-shadow 0.15s; + } + .dh-input::placeholder { + color: var(--text-muted); + } + .dh-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: var(--shadow-focus); + } + + .dh-label { + display: block; + margin-bottom: 0.375rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-body); + } + + /* Status pills — soft tint + solid text. */ + .dh-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + border-radius: var(--radius-pill); + padding: 0.125rem 0.625rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.25rem; + } + .dh-badge-success { + background: var(--success-100); + color: var(--success-600); + } + .dh-badge-warning { + background: var(--warning-100); + color: var(--warning-600); + } + .dh-badge-danger { + background: var(--danger-100); + color: var(--danger-600); + } + .dh-badge-neutral { + background: var(--surface-sunken); + color: var(--text-muted); + } +} diff --git a/Web App/src/views/AdminUsers.vue b/Web App/src/views/AdminUsers.vue new file mode 100644 index 0000000..add7baa --- /dev/null +++ b/Web App/src/views/AdminUsers.vue @@ -0,0 +1,231 @@ + + + diff --git a/Web App/src/views/CarDetail.vue b/Web App/src/views/CarDetail.vue new file mode 100644 index 0000000..05752fc --- /dev/null +++ b/Web App/src/views/CarDetail.vue @@ -0,0 +1,364 @@ + + + diff --git a/Web App/src/views/Dashboard.vue b/Web App/src/views/Dashboard.vue new file mode 100644 index 0000000..c81fa1e --- /dev/null +++ b/Web App/src/views/Dashboard.vue @@ -0,0 +1,143 @@ + + + diff --git a/Web App/src/views/Login.vue b/Web App/src/views/Login.vue new file mode 100644 index 0000000..acad6f3 --- /dev/null +++ b/Web App/src/views/Login.vue @@ -0,0 +1,114 @@ + + + diff --git a/Web App/src/views/Settings.vue b/Web App/src/views/Settings.vue new file mode 100644 index 0000000..f905661 --- /dev/null +++ b/Web App/src/views/Settings.vue @@ -0,0 +1,682 @@ + + +