From ba3f2273618ee99b98bd84292089fbd68e651542 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:50:52 +0200 Subject: [PATCH] Initial commit --- .claude/launch.json | 32 + .claude/settings.local.json | 35 + .gitignore | 17 + API Server/.dockerignore | 16 + API Server/.env.example | 22 + API Server/.gitignore | 4 + API Server/Dockerfile | 39 + API Server/README.md | 190 ++ API Server/docker-compose.yml | 20 + API Server/go.mod | 3 + API Server/internal/api/admin.go | 260 +++ API Server/internal/api/auth.go | 272 +++ API Server/internal/api/cars.go | 235 ++ .../api/dist/assets/index-DfVJ8vbT.css | 1 + .../api/dist/assets/index-o_I931vi.js | 17 + API Server/internal/api/dist/favicon.svg | 10 + API Server/internal/api/dist/index.html | 15 + API Server/internal/api/me.go | 534 +++++ API Server/internal/api/panel.go | 23 + API Server/internal/api/parts.go | 124 ++ API Server/internal/api/records.go | 192 ++ API Server/internal/api/server.go | 225 ++ API Server/internal/api/services.go | 188 ++ API Server/internal/api/sessions.go | 106 + API Server/internal/api/shares.go | 202 ++ API Server/internal/auth/jwt.go | 96 + API Server/internal/config/config.go | 95 + API Server/internal/models/models.go | 140 ++ API Server/internal/pb/client.go | 362 +++ API Server/main.go | 79 + API Server/panel/index.html | 14 + API Server/panel/package-lock.json | 1964 +++++++++++++++++ API Server/panel/package.json | 20 + API Server/panel/public/favicon.svg | 10 + API Server/panel/src/App.vue | 196 ++ .../panel/src/components/EndpointTable.vue | 44 + API Server/panel/src/main.js | 6 + API Server/panel/src/style.css | 219 ++ API Server/panel/src/theme.js | 35 + API Server/panel/vite.config.js | 20 + API Server/scripts/backfill-car-owners.mjs | 82 + API Server/scripts/create-user.mjs | 76 + API Server/scripts/seed_from_excel.py | 156 ++ API Server/scripts/set-role.mjs | 63 + API Server/scripts/setup-pocketbase.mjs | 311 +++ Docker AIO/.env.example | 20 + Docker AIO/Dockerfile | 161 ++ Docker AIO/docker-compose.yml | 35 + Docker/.env.example | 22 + Docker/docker-compose.yml | 73 + Docker/pocketbase/Dockerfile | 34 + Docker/pocketbase/entrypoint.sh | 12 + Phone App/.flutter-plugins | 9 + Phone App/.gitignore | 45 + Phone App/.metadata | 33 + Phone App/README.md | 102 + Phone App/analysis_options.yaml | 5 + Phone App/android/.gitignore | 14 + Phone App/android/app/build.gradle.kts | 45 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 48 + .../carcontrol_phone/MainActivity.kt | 7 + .../drawable-hdpi/ic_launcher_foreground.png | Bin 0 -> 3961 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 0 -> 2990 bytes .../res/drawable-v21/launch_background.xml | 12 + .../drawable-xhdpi/ic_launcher_foreground.png | Bin 0 -> 4957 bytes .../ic_launcher_foreground.png | Bin 0 -> 7384 bytes .../ic_launcher_foreground.png | Bin 0 -> 9271 bytes .../main/res/drawable/launch_background.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2121 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 1360 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 2729 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 4114 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 5452 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/colors.xml | 4 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + Phone App/android/build.gradle.kts | 24 + Phone App/android/gradle.properties | 10 + .../gradle/wrapper/gradle-wrapper.properties | 5 + Phone App/android/settings.gradle.kts | 26 + Phone App/assets/icon/drivervault-fg.png | Bin 0 -> 11475 bytes Phone App/assets/icon/drivervault.png | Bin 0 -> 14382 bytes Phone App/lib/api.dart | 254 +++ Phone App/lib/app_settings.dart | 70 + Phone App/lib/auth.dart | 74 + Phone App/lib/biometric.dart | 114 + Phone App/lib/config.dart | 14 + Phone App/lib/format.dart | 85 + Phone App/lib/main.dart | 109 + Phone App/lib/models.dart | 300 +++ Phone App/lib/screens/admin_users_screen.dart | 296 +++ Phone App/lib/screens/car_detail_screen.dart | 1162 ++++++++++ Phone App/lib/screens/car_form_sheet.dart | 249 +++ Phone App/lib/screens/dashboard_screen.dart | 355 +++ Phone App/lib/screens/lock_screen.dart | 110 + Phone App/lib/screens/login_screen.dart | 383 ++++ Phone App/lib/screens/root_shell.dart | 61 + Phone App/lib/screens/settings_screen.dart | 915 ++++++++ Phone App/lib/theme.dart | 283 +++ Phone App/pubspec.lock | 754 +++++++ Phone App/pubspec.yaml | 38 + Phone App/web/favicon.png | Bin 0 -> 917 bytes Phone App/web/icons/Icon-192.png | Bin 0 -> 5292 bytes Phone App/web/icons/Icon-512.png | Bin 0 -> 8252 bytes Phone App/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes Phone App/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes Phone App/web/index.html | 46 + Phone App/web/manifest.json | 35 + README.md | 94 + Web App/.claude/launch.json | 11 + Web App/.dockerignore | 15 + Web App/.gitignore | 24 + Web App/Dockerfile | 33 + Web App/README.md | 75 + Web App/docker-compose.yml | 18 + Web App/index.html | 16 + Web App/nginx.conf.template | 35 + Web App/package-lock.json | 1501 +++++++++++++ Web App/package.json | 21 + Web App/public/apple-touch-icon.png | Bin 0 -> 6847 bytes .../public/brand/drivervault-appicon-256.png | Bin 0 -> 6847 bytes Web App/public/brand/drivervault-appicon.svg | 10 + .../public/brand/drivervault-icon-mono.svg | 7 + .../public/brand/drivervault-icon-white.svg | 7 + Web App/public/brand/drivervault-icon.svg | 7 + .../public/brand/drivervault-lockup-dark.svg | 11 + Web App/public/brand/drivervault-lockup.svg | 10 + Web App/public/favicon-16.png | Bin 0 -> 405 bytes Web App/public/favicon-32.png | Bin 0 -> 905 bytes Web App/public/favicon.svg | 10 + Web App/src/App.vue | 126 ++ Web App/src/api.js | 149 ++ Web App/src/auth.js | 51 + Web App/src/components/CarFormModal.vue | 173 ++ Web App/src/components/Logo.vue | 29 + Web App/src/components/Modal.vue | 13 + Web App/src/components/PartFormModal.vue | 63 + Web App/src/components/ServiceFormModal.vue | 91 + Web App/src/components/ShareModal.vue | 118 + Web App/src/lib/format.js | 94 + Web App/src/main.js | 8 + Web App/src/prefs.js | 47 + Web App/src/router.js | 37 + Web App/src/style.css | 360 +++ Web App/src/views/AdminUsers.vue | 231 ++ Web App/src/views/CarDetail.vue | 364 +++ Web App/src/views/Dashboard.vue | 143 ++ Web App/src/views/Login.vue | 114 + Web App/src/views/Settings.vue | 682 ++++++ Web App/vite.config.js | 24 + 153 files changed, 18116 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 API Server/.dockerignore create mode 100644 API Server/.env.example create mode 100644 API Server/.gitignore create mode 100644 API Server/Dockerfile create mode 100644 API Server/README.md create mode 100644 API Server/docker-compose.yml create mode 100644 API Server/go.mod create mode 100644 API Server/internal/api/admin.go create mode 100644 API Server/internal/api/auth.go create mode 100644 API Server/internal/api/cars.go create mode 100644 API Server/internal/api/dist/assets/index-DfVJ8vbT.css create mode 100644 API Server/internal/api/dist/assets/index-o_I931vi.js create mode 100644 API Server/internal/api/dist/favicon.svg create mode 100644 API Server/internal/api/dist/index.html create mode 100644 API Server/internal/api/me.go create mode 100644 API Server/internal/api/panel.go create mode 100644 API Server/internal/api/parts.go create mode 100644 API Server/internal/api/records.go create mode 100644 API Server/internal/api/server.go create mode 100644 API Server/internal/api/services.go create mode 100644 API Server/internal/api/sessions.go create mode 100644 API Server/internal/api/shares.go create mode 100644 API Server/internal/auth/jwt.go create mode 100644 API Server/internal/config/config.go create mode 100644 API Server/internal/models/models.go create mode 100644 API Server/internal/pb/client.go create mode 100644 API Server/main.go create mode 100644 API Server/panel/index.html create mode 100644 API Server/panel/package-lock.json create mode 100644 API Server/panel/package.json create mode 100644 API Server/panel/public/favicon.svg create mode 100644 API Server/panel/src/App.vue create mode 100644 API Server/panel/src/components/EndpointTable.vue create mode 100644 API Server/panel/src/main.js create mode 100644 API Server/panel/src/style.css create mode 100644 API Server/panel/src/theme.js create mode 100644 API Server/panel/vite.config.js create mode 100644 API Server/scripts/backfill-car-owners.mjs create mode 100644 API Server/scripts/create-user.mjs create mode 100644 API Server/scripts/seed_from_excel.py create mode 100644 API Server/scripts/set-role.mjs create mode 100644 API Server/scripts/setup-pocketbase.mjs create mode 100644 Docker AIO/.env.example create mode 100644 Docker AIO/Dockerfile create mode 100644 Docker AIO/docker-compose.yml create mode 100644 Docker/.env.example create mode 100644 Docker/docker-compose.yml create mode 100644 Docker/pocketbase/Dockerfile create mode 100644 Docker/pocketbase/entrypoint.sh create mode 100644 Phone App/.flutter-plugins create mode 100644 Phone App/.gitignore create mode 100644 Phone App/.metadata create mode 100644 Phone App/README.md create mode 100644 Phone App/analysis_options.yaml create mode 100644 Phone App/android/.gitignore create mode 100644 Phone App/android/app/build.gradle.kts create mode 100644 Phone App/android/app/src/debug/AndroidManifest.xml create mode 100644 Phone App/android/app/src/main/AndroidManifest.xml create mode 100644 Phone App/android/app/src/main/kotlin/com/carcontrole/carcontrol_phone/MainActivity.kt create mode 100644 Phone App/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png create mode 100644 Phone App/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png create mode 100644 Phone App/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 Phone App/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png create mode 100644 Phone App/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png create mode 100644 Phone App/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 Phone App/android/app/src/main/res/drawable/launch_background.xml create mode 100644 Phone App/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 Phone App/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 Phone App/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 Phone App/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 Phone App/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 Phone App/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 Phone App/android/app/src/main/res/values-night/styles.xml create mode 100644 Phone App/android/app/src/main/res/values/colors.xml create mode 100644 Phone App/android/app/src/main/res/values/styles.xml create mode 100644 Phone App/android/app/src/profile/AndroidManifest.xml create mode 100644 Phone App/android/build.gradle.kts create mode 100644 Phone App/android/gradle.properties create mode 100644 Phone App/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 Phone App/android/settings.gradle.kts create mode 100644 Phone App/assets/icon/drivervault-fg.png create mode 100644 Phone App/assets/icon/drivervault.png create mode 100644 Phone App/lib/api.dart create mode 100644 Phone App/lib/app_settings.dart create mode 100644 Phone App/lib/auth.dart create mode 100644 Phone App/lib/biometric.dart create mode 100644 Phone App/lib/config.dart create mode 100644 Phone App/lib/format.dart create mode 100644 Phone App/lib/main.dart create mode 100644 Phone App/lib/models.dart create mode 100644 Phone App/lib/screens/admin_users_screen.dart create mode 100644 Phone App/lib/screens/car_detail_screen.dart create mode 100644 Phone App/lib/screens/car_form_sheet.dart create mode 100644 Phone App/lib/screens/dashboard_screen.dart create mode 100644 Phone App/lib/screens/lock_screen.dart create mode 100644 Phone App/lib/screens/login_screen.dart create mode 100644 Phone App/lib/screens/root_shell.dart create mode 100644 Phone App/lib/screens/settings_screen.dart create mode 100644 Phone App/lib/theme.dart create mode 100644 Phone App/pubspec.lock create mode 100644 Phone App/pubspec.yaml create mode 100644 Phone App/web/favicon.png create mode 100644 Phone App/web/icons/Icon-192.png create mode 100644 Phone App/web/icons/Icon-512.png create mode 100644 Phone App/web/icons/Icon-maskable-192.png create mode 100644 Phone App/web/icons/Icon-maskable-512.png create mode 100644 Phone App/web/index.html create mode 100644 Phone App/web/manifest.json create mode 100644 README.md create mode 100644 Web App/.claude/launch.json create mode 100644 Web App/.dockerignore create mode 100644 Web App/.gitignore create mode 100644 Web App/Dockerfile create mode 100644 Web App/README.md create mode 100644 Web App/docker-compose.yml create mode 100644 Web App/index.html create mode 100644 Web App/nginx.conf.template create mode 100644 Web App/package-lock.json create mode 100644 Web App/package.json create mode 100644 Web App/public/apple-touch-icon.png create mode 100644 Web App/public/brand/drivervault-appicon-256.png create mode 100644 Web App/public/brand/drivervault-appicon.svg create mode 100644 Web App/public/brand/drivervault-icon-mono.svg create mode 100644 Web App/public/brand/drivervault-icon-white.svg create mode 100644 Web App/public/brand/drivervault-icon.svg create mode 100644 Web App/public/brand/drivervault-lockup-dark.svg create mode 100644 Web App/public/brand/drivervault-lockup.svg create mode 100644 Web App/public/favicon-16.png create mode 100644 Web App/public/favicon-32.png create mode 100644 Web App/public/favicon.svg create mode 100644 Web App/src/App.vue create mode 100644 Web App/src/api.js create mode 100644 Web App/src/auth.js create mode 100644 Web App/src/components/CarFormModal.vue create mode 100644 Web App/src/components/Logo.vue create mode 100644 Web App/src/components/Modal.vue create mode 100644 Web App/src/components/PartFormModal.vue create mode 100644 Web App/src/components/ServiceFormModal.vue create mode 100644 Web App/src/components/ShareModal.vue create mode 100644 Web App/src/lib/format.js create mode 100644 Web App/src/main.js create mode 100644 Web App/src/prefs.js create mode 100644 Web App/src/router.js create mode 100644 Web App/src/style.css create mode 100644 Web App/src/views/AdminUsers.vue create mode 100644 Web App/src/views/CarDetail.vue create mode 100644 Web App/src/views/Dashboard.vue create mode 100644 Web App/src/views/Login.vue create mode 100644 Web App/src/views/Settings.vue create mode 100644 Web App/vite.config.js 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 0000000000000000000000000000000000000000..25fcc7301743a45b8d1baab98612f6d133e6f6e7 GIT binary patch literal 3961 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 ABme*a literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..240ce7c1c0fad7df3ece0ca7b1b606434fda7c4e GIT binary patch literal 2990 zcmb7GX*d+@8Xn&#F;Nqf8IrPZlg1Ka>|{%fp|KT`Io7cgiW>VimKe)eK6}YdiXs!4 zEHg~DkQjsPk}Sn==KMIn&vl;bdf(^A`&`eD`+lDHz7s8P8}R@IfdBx2$Hdscij}?p zb2vFzcNzvw0RYYfn;7U?KP=cNJmZa;5FlGTtxNBjnwpx}^ zIU=_Z6w|NoaxU0BJxLi1O~xrpST`i<0Jy|IxnFM;9{{DBw(_c86zdH2_ zFh{F0Mc*lip$SFqk*}kpLB@R( zW2efxFwJ>s!kXb!Zo;c!EFATU0hhm!p?Sfv~+eJ$Fs1A>2|SW zB#(JsEphLsl$4a1fg|_b#<>U?8QTYQb8~BmAywnal1<8DKu2rPij^H77oO2?Jk1M? zl{YM-4H+}bbAxfaySp2t#B7{P?$pnpKP9lQ4>b#XkjV4fd@72H^CG(>g_rCv>+78` zuct%P4{TwCMKzKw;kqDqoNq=enX$c$GieOn`nGp+yrY(A5gB=m_ES+I)(QgPX)T%{ zy^nh!)Sw`V)w<|Dbm_1>GV=Y2Ahj3!dLq5oT=JO+609x0+pXZmKHcImVw(S63dEUW z#?+FNI}@CEq1>Mt`M$Kd8{B*oPH;n`-rU`Ph{$B1kw}^<8^B472gZq;XFwi)!HrE! zK7-dd>$k+U;< z4HHTa_){M81mRCqoXhaJi7#IcOdTAi2R1e~qSE=&+$KK) zp7^G>b}t6F%v$H~LI870oOCy!Hp~Muh-uc`@>636JQ-ad93CGZS3)4xgWt|<&EF?v zWeu(}VNr1M7vPa}Qt7&Yyo|^BL3d?p zvn{NLG5F=n7Y#Kc4)<3zSG*8uI3QYCKE{%EG;!;*D0HC7eePKCMR7r0Y{1l|cu=^4 zKT>$Z?sHd1M@KYxBg5RDz3dUnjjMMD;qT2!CvHDH!e{Zg5q;mzY^ur}J$S}XlCGNJ zV=x$*UM%)$1Y}(n1w!{zYnb6TM-z?3ai9-r#!O@5WM}25M^`|gQrfbQ%tbuIU5hhx zF-1xFvf_1Kf)es>!_m=^l({6|N|3Nx;cf3%=P4~3dAB;Zg4DIsoB9R@9E?#S6-$5Q zVpnn7i1#?j-%?eG6pSmsUc!vFV_U$YY(7xt`h=5=H6+E&E*~p5AEZ8;qX;Kbc0Bk8 zh0{(GtEykTurm~dq_&BPiD?0~MOl@Yf~TPSWZdV^R$$d<%6p$aafU!TN=tESE&{;V zxt^Ywg+UaT^#z!moR@Gnyk~}Rg)@pY`NdmhzD2W|F9u|aJy`nwUE2o^O)~zws!W63 z^9*YPXdCgSz1whTR!!J785tK3 zOwql1h#Zvc-!t;tK=k|*(0$eFY@O?*fWSbA9gKgTaU-CeKYv5|ife%`ag@8e=1Qst z0#UPiOb@c=t6@cgqVF=-`Rw5%06cx&! zF0L0yC(7cGm6{KHOpxPT;W*MjJ%C+NJ$?=$H;jrIpa zU>X_`MU&o=oem!E?#pRv7xP_T0-jiKaBzr|ulf0YwUK!Fk8;u(!n0@5n=bYS%OQvB zlg*lHWto}0^FCrN^aw^TPhVaY1v3zlg$J9I+{`)fwK}Z}iu@j=9<8R4Ux6#|P^EC0 z{St`=nc~|#|9Qhga4TFf@92t>QedOd;R8j1Z%uAz_O7&1FcJ7lJyYtp2Vr41{HrDT zlRMV-54A;hBP_uXZk`XCnws_3Ra81T_3?|8dC7T={)S@^>s5)PHgkKRER$ zUujSQ2|l?=-|s4~_z4+nBs2VF8v%wtFg#~qY?4N!r8X1Qd(99k_|()D99;*sd`Tn38}aam^J@c5COQ7>B~K45 zb~BR?MR9%SS;|EM6&4oyK13AFv-G4GjYiiO$?CqL@BFSSVBwPLO2!u^-7Mp3+m@bi zQZ+>ORCE=Dd ziG*xS?IyRkx7)wC2I6G@tNYcfS9{KI>1!kT97{FMG>ggz(7>YX60*Jn3K`0?0I0UC`&*xcs + + + + + + + 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 0000000000000000000000000000000000000000..f94ee450bc7a6ba3cd6212491f8a188441059302 GIT binary patch literal 4957 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 JmBy~O{||qV)WZM( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6afcad4bf4e71ad637caeb0cfa427830fe70ccb1 GIT binary patch literal 9271 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 + + + + + + + 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 0000000000000000000000000000000000000000..45605eb5c14ae44f44dfecaf830045b269ceb64b GIT binary patch literal 2121 zcmV-P2)6f$P)vpt`K)%*%Wme6XYY!N7)O&elvMBHe3293$_T#f-QowU|kYqug!x$ z{H_6Z)#t;|EkFGJbQg?Ig+%$Rbw|*)GCiDq^Fh$zi31j!B@a#+MzZmif4UCQl;QZ$kKvByBtG|cHc!!x{F-{6%reHd*^?Af|S}KCdi*<1L zKqa6{b%UBJBfC6b?_WL+udB|@cyD0Tm%3(hOp`S;g0A(s#d4d4Bm_O!WCm2le=FD$_fC+r-D3T*I;4A-;zb* zqIY;cWy3^aRk@yL!^naE-Ee0J#I^953sHq4<9vc3dg(49au|Y=a;Kz73x)YOtACaT z&OYfI@uh6`+7r>kwHbGXc++&Pd``C!b&8n?O-Ax%20b~mdj4_Fgu%D4xIRDx70{vzS^ErR3LviPgpo|~}>F@tw< za+J{W&Zy^jJfm3@h)U3xU9)iTM`dg&!#NXzbC)J~J8z)`J$TE{D_!ii$(MgXY=Zhn zd~oxd1$N&R2t|1V2UZ{o5X6~YD2hO1oe)9N6PvGw4ND5{PTzGZ(T}6bD9puyi+$<+lX@}oU++#FXR=_AhE(B zWF)>hj-KE6`w%?5uau1vW?wZh>gPQd5{SJb7U+c_D?yB4Z2fE^b)PAeS*bQ#Q!ERxL=jZ# zwPC%0jx!MyOngDW7Sch}5W?b=->Fg)HF7(nM8u02AtR;NC;HeHH?w-{g-jV zdRT5}l!!(DNEa6Vw)116^2nQzT@q|Mke=>K7!@2FTU;1v$8#+QS29$Zc$2~=8gUPh z-o6f22hETDtHo@4@nNCmUWwvO3LF3Uh0{P&G)qv#bG4iF^nIW;*^aK(>1F{Hn!Xa4 z`u9J9yIJ)(-hQ@Z&l^8MFnyV<-pLI*8gvi7bqwej8(k@aVv94LxuLfX7g=BU0uW3m ztCRA9_Kv>yYbr1_oNVCj#YlJ8Rrlz7M+$ZvZv`4~SzMG2H0^V?KM`=Yf5dIg*sO+} z?Vr#Q&;EkC6aQeo=tEK;!0c}7drJR*sc zGJy(BTyZdo9T9rIV!0z4nw**Kd8t`ny8jnM<)g2n8dHO)o=S{pWZtnbajs>- z*?u;ekcE)79u%DVtS#vJybaH_HEIiY?#B()_o?;ebt*Mc0suJ(lBg*uMc}2|&@|2b zwN^MD4a`QvE=R;a+(mi^E|H#V9Y9lA|3>6HM08!@9$9c000000NkvXXu0mjfC$Z*n literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a42b2c8b28767114a1a7710a5f7eb7be67f6ce90 GIT binary patch literal 1360 zcmV-W1+V&vP)#~P^LuWYGuieqW zpzUsbsd)|R^+v7f<@Xt_b=PtUzLR8JR7kVNX8x03_Qb)6f9N}}qw8=iIO@pIn|{F> z+MdzXynd7*Y2C7`UIg9{Q(XUSY12DTdhA^tM6hiZ7Wu<@! zTBEDk_Z5c3b0JsHDM%S0nGHt0`PE}9S$qnF5UDr4ax50Sz9kkI9ufrWOncuWNM=Q) z&0-*e;%DN6nf^oDC_2BQn$cT#D=q4NQ596QtBN@)22Lnu6#b&Mr7u>S=Um@ATHrq!w!4K&tk z#Xc8pURg16Vt~)-7K>iU( zpmS#>`z6gx}!^w&-+rWAQE1pMVbKv%bh4WPI^Gv!z5SmTis7(!$0lDb| zIWbTw8JL=x74n~_J}v)K>BBFXbybovx#M}wXLeX&YxA9g0w zr`e(dij=#Sk2Np?+-*_;&%I^o23HAi=UNIz*5LEP*j*Fo{wG+LO7BXWJ#NWK}RzGRe3~) zu|EfH$O$bX%K$Gzts=o6hzSLZM^}^whBZ>58nh=3Sk1*i(^9%!xO`JiXc1WkJiaKL z?3u`DtJ;_^KIA1582YXzg*IEH{EyEi;KG#^VBl_yO>#osvuX-H`_?Jm*iC8spOLWu zaG`ik;^o}H4Q~WG-#!lnfgl=__V|r;QUU89%+0qCOSKz~&tZT$oYBTFn>@Zi6h1xa z%vr2ud!Xrfvlq5((!=k4Zur*|$=SHeW?;e<5mv%xX0d@!QT)-1INq`RjHZQtCHhIfQ0>{MFN{SXaKo)3HfJ|`@rK1bJEO!K?j`QKrc7WRL@I1%&OKRy6}lXW1* zIUOGRN1ZzJYhNP(^4>!z2gZ2LJKDV`;ve`6(hC{q`soA!>d=|?9YqAmE;@=r2;cnZ z=X-;b-+z&6%c#VhtLH>CIA+t=?Ef&YpmM7sDig99(PW}ToKgIfK Sp=_uC0000 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9f4d8f593b5a7bc7351db685934ca0e7a34bd6cf GIT binary patch literal 4114 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 + + + + + + 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 0000000000000000000000000000000000000000..3ec14ac7c65543210d50c608889d73dd60363eb0 GIT binary patch literal 11475 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

nF;5ri{KMg( zi8uoweyi*c41_PeP>6&UZ4~UYTw`-dQ+;${P zADFd65x1Nf9QDwRYm-cWn@EA#f6wpdup<=5bM2O;1RRM#t}6y8dJ^e89|1n%jxNcy(@9`e|v zpzjiEa`}xd(<|&{FfVMh^NV&61M5PN(?$ES7`*ftHIRZTLwe;#(-^Sue5yoi+KK@W zOBz!6+i2B6*Pt6c;Qtrvh1B~}6ch73kZsbaQ1T4$-u6T*Y8B_u$+hip5fN}$177-% zMyh-^yQ#-`T;34X-+6BWkyY=beA9TfTt-52Cau8qQzZOJNy^?UDwJK&TV8Wb7ebKkRm%1H{-*K0 z6w)}d(SMLYj8bNq$TB&RTQfrC;~qufi&%oj(~5fgi*z2>%Dl%yIBp1&r_Q(08^0Bs<55b(lcahJ zEkl8J2kSHPN`%<)d+0Qev?e`I&6hfgv2^gI88vjes5o~{$$CZwrU?^WUt{y4YW{Z# zo23YC`RWTm%9fzn-T|w=B)gGWu%6UREUz5X6F`7!Qnvl*9S)Om^<8$bfyhX;--tAu z%>U42ybwCcDdDo=eUJUfdFMkIn7;8gdYsqlBf%*DTKNO-s&0o!OsFCyneay{1T5Q? z*8}6dPOpv*e;I!0hDgYnmJ`lvG6_j=NCAQ|^ByG?>GZG=FrLs3{vXhxv>-x+j6`1f zL(f0IA*BpFlIaj!Sz(rkhy_HXAlsy#$I#BrEIRzz33O3aE-gLnb6unU2m9)nO|J7- zAgitK>5~}3JqHQ)MT3V_7M&vnQDdXc1){qjYo|O-Fq3 zj=lH&`9S~$P{GxQ%PVY%1>F>n52XUY@0s3u|GV$*u-h9VVU)1x2(Ukt0qf0pw{FaaqkM#u{O^kE3zPSFdjU%0E3 z8190bV}klCwt!I@p}2cWK@kux6`a-*y?A4B#!LX>^4(eYe*tXkS>@NP=egS4W$&ZoHvL z%PU&{P$8oFzSVuPXUukb_-k;}6{c82i(gt}W+^7%ISqAV)QRdNhaB#PSbaRnyP44v z6$#DTSJ&pf-MxW>*~mBc>s+kU24MIyLT@uUJfDDARS+5+MZm~5jYZ|&NO-_aNY-O?hNixAQ^dxdHUq>}t8>8mH-K4p1QG0# zg5{!vU^^|^Xg|XP8(+ba%dA>SGDIfD5bLRe-VO`5ilgDW2*O74mi2U<6-O!>=liVE ztr&pRZ|F6r`pN`Dea3Bo`cT;0pQCV`m%f%*cisG6zq%ediV1IZ!zbO$A?Cp^`~$di zKjW5KdT0n~99=!H(sb@TpD#)MQ`QjP)gF!~ulwYoM9*l&8QEQ)7w}vw>56cwNrpEb zvU)7MUesiy58VyIwG6=Y@b_|4g~->op}DC{Mhfl2J}Yh^Vn~MK!WwIt18{c;2-1T)-&PZ zP`$sedi?N>5a+xz$pbG>)T*KPMId4}hHLLjYj`0fam@)On(EqVwmz#xc zJ4aK;{G37MSFL@hXVtLI_V8muv?IMuH>hpN- z$!SV|A=vzIr|fl-disAa*T7pn`J#fXC-6c9V|yEa+2FB=+Qf8E?>nx7&f!<9ce6-P z1@OKOS;zgZ(%ypLAAUF~cx0|KB3@Kg4x#JCv0uWw@-GA1bQ6;eC#|X<7u5+tyEnSD z?3)7qI;-}!)+qmFK#E$iboX!?drj>ckazIv=IEF|5tx>;5t(X}(bCP%jK&z3G#)%x zM*&r;Nh9LGZ(YJg7V8%;fr>W%;$EIWi{O@Ri~aKfgbPl+8!Zu*(Kju0Q!oE_!D){& zsj%$sHNnrqZll$Uqj6%e6Tjm+_ckwtrTg#8${ zwrS$G(!QzrKewmCIK1x~+ps`TIZapTBxkHleZe^k60>_gGui zffR)zz=^qguOB%olH~Od={Sc4#U0=QSU1(0(l 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 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/Phone App/web/icons/Icon-192.png b/Phone App/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/Phone App/web/icons/Icon-512.png b/Phone App/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 zcmeFZ_gj-)&^4Nb2tlbLMU<{!p(#yjqEe+=0IA_oih%ScH9@5#MNp&}Y#;;(h=A0@ zh7{>lT2MkSQ344eAvrhici!td|HJuyvJm#Y_w1Q9Yu3!26dNlO-oxUDK_C#XnW^Co z5C{VN6#{~B0)K2j7}*1Xq(Nqemv23A-6&=ZpEijkVnSwVGqLv40?n0=p;k3-U5e5+ z+z3>aS`u9DS=!wg8ROu?X4TFoW6CFLL&{GzoVT)ldhLekLM|+j3tIxRd|*5=c{=s&*vfPdBr(Fyj(v@%eQj1Soy7m4^@VRl1~@-PV7y+c!xz$8436WBn$t{=}mEdK#k`aystimGgI{(IBx$!pAwFoE9Y`^t^;> zKAD)C(Dl^s%`?q5$P|fZf8Xymrtu^Pv(7D`rn>Z-w$Ahs!z9!94WNVxrJuXfHAaxg zC6s@|Z1$7R$(!#t%Jb{{s6(Y?NoQXDYq)!}X@jKPhe`{9KQ@sAU8y-5`xt?S9$jKH zoi}6m5PcG*^{kjvt+kwPpyQzVg4o)a>;LK`aaN2x4@itBD3Aq?yWTM20VRn1rrd+2 zKO=P0rMjEGq_UqpMa`~7B|p?xAN1SCoCp}QxAv8O`jLJ5CVh@umR%c%i^)6!o+~`F zaalSTQcl5iwOLC&H)efzd{8(88mo`GI(56T<(&p7>Qd^;R1hn1Y~jN~tApaL8>##U zd65bo8)79CplWxr#z4!6HvLz&N7_5AN#x;kLG?zQ(#p|lj<8VUlKY=Aw!ATqeL-VG z42gA!^cMNPj>(`ZMEbCrnkg*QTsn*u(nQPWI9pA{MQ=IsPTzd7q5E#7+z>Ch=fx$~ z;J|?(5jTo5UWGvsJa(Sx0?S#56+8SD!I^tftyeh_{5_31l6&Hywtn`bbqYDqGZXI( zCG7hBgvksX2ak8+)hB4jnxlO@A32C_RM&g&qDSb~3kM&)@A_j1*oTO@nicGUyv+%^ z=vB)4(q!ykzT==Z)3*3{atJ5}2PV*?Uw+HhN&+RvKvZL3p9E?gHjv{6zM!A|z|UHK z-r6jeLxbGn0D@q5aBzlco|nG2tr}N@m;CJX(4#Cn&p&sLKwzLFx1A5izu?X_X4x8r@K*d~7>t1~ zDW1Mv5O&WOxbzFC`DQ6yNJ(^u9vJdj$fl2dq`!Yba_0^vQHXV)vqv1gssZYzBct!j zHr9>ydtM8wIs}HI4=E}qAkv|BPWzh3^_yLH(|kdb?x56^BlDC)diWyPd*|f!`^12_U>TD^^94OCN0lVv~Sgvs94ecpE^}VY$w`qr_>Ue zTfH~;C<3H<0dS5Rkf_f@1x$Gms}gK#&k()IC0zb^QbR!YLoll)c$Agfi6MKI0dP_L z=Uou&u~~^2onea2%XZ@>`0x^L8CK6=I{ge;|HXMj)-@o~h&O{CuuwBX8pVqjJ*o}5 z#8&oF_p=uSo~8vn?R0!AMWvcbZmsrj{ZswRt(aEdbi~;HeVqIe)-6*1L%5u$Gbs}| zjFh?KL&U(rC2izSGtwP5FnsR@6$-1toz?RvLD^k~h9NfZgzHE7m!!7s6(;)RKo2z} zB$Ci@h({l?arO+vF;s35h=|WpefaOtKVx>l399}EsX@Oe3>>4MPy%h&^3N_`UTAHJ zI$u(|TYC~E4)|JwkWW3F!Tib=NzjHs5ii2uj0^m|Qlh-2VnB#+X~RZ|`SA*}}&8j9IDv?F;(Y^1=Z0?wWz;ikB zewU>MAXDi~O7a~?jx1x=&8GcR-fTp>{2Q`7#BE#N6D@FCp`?ht-<1|y(NArxE_WIu zP+GuG=Qq>SHWtS2M>34xwEw^uvo4|9)4s|Ac=ud?nHQ>ax@LvBqusFcjH0}{T3ZPQ zLO1l<@B_d-(IS682}5KA&qT1+{3jxKolW+1zL4inqBS-D>BohA!K5++41tM@ z@xe<-qz27}LnV#5lk&iC40M||JRmZ*A##K3+!j93eouU8@q-`W0r%7N`V$cR&JV;iX(@cS{#*5Q>~4BEDA)EikLSP@>Oo&Bt1Z~&0d5)COI%3$cLB_M?dK# z{yv2OqW!al-#AEs&QFd;WL5zCcp)JmCKJEdNsJlL9K@MnPegK23?G|O%v`@N{rIRa zi^7a}WBCD77@VQ-z_v{ZdRsWYrYgC$<^gRQwMCi6);%R~uIi31OMS}=gUTE(GKmCI z$zM>mytL{uNN+a&S38^ez(UT=iSw=l2f+a4)DyCA1Cs_N-r?Q@$3KTYosY!;pzQ0k zzh1G|kWCJjc(oZVBji@kN%)UBw(s{KaYGy=i{g3{)Z+&H8t2`^IuLLKWT6lL<-C(! zSF9K4xd-|VO;4}$s?Z7J_dYqD#Mt)WCDnsR{Kpjq275uUq6`v0y*!PHyS(}Zmv)_{>Vose9-$h8P0|y;YG)Bo}$(3Z%+Gs0RBmFiW!^5tBmDK-g zfe5%B*27ib+7|A*Fx5e)2%kIxh7xWoc3pZcXS2zik!63lAG1;sC1ja>BqH7D zODdi5lKW$$AFvxgC-l-)!c+9@YMC7a`w?G(P#MeEQ5xID#<}W$3bSmJ`8V*x2^3qz zVe<^^_8GHqYGF$nIQm0Xq2kAgYtm#UC1A(=&85w;rmg#v906 zT;RyMgbMpYOmS&S9c38^40oUp?!}#_84`aEVw;T;r%gTZkWeU;;FwM@0y0adt{-OK z(vGnPSlR=Nv2OUN!2=xazlnHPM9EWxXg2EKf0kI{iQb#FoP>xCB<)QY>OAM$Dcdbm zU6dU|%Mo(~avBYSjRc13@|s>axhrPl@Sr81{RSZUdz4(=|82XEbV*JAX6Lfbgqgz584lYgi0 z2-E{0XCVON$wHfvaLs;=dqhQJ&6aLn$D#0i(FkAVrXG9LGm3pSTf&f~RQb6|1_;W> z?n-;&hrq*~L=(;u#jS`*Yvh@3hU-33y_Kv1nxqrsf>pHVF&|OKkoC)4DWK%I!yq?P z=vXo8*_1iEWo8xCa{HJ4tzxOmqS0&$q+>LroMKI*V-rxhOc%3Y!)Y|N6p4PLE>Yek>Y(^KRECg8<|%g*nQib_Yc#A5q8Io z6Ig&V>k|~>B6KE%h4reAo*DfOH)_01tE0nWOxX0*YTJgyw7moaI^7gW*WBAeiLbD?FV9GSB zPv3`SX*^GRBM;zledO`!EbdBO_J@fEy)B{-XUTVQv}Qf~PSDpK9+@I`7G7|>Dgbbu z_7sX9%spVo$%qwRwgzq7!_N;#Td08m5HV#?^dF-EV1o)Q=Oa+rs2xH#g;ykLbwtCh znUnA^dW!XjspJ;otq$yV@I^s9Up(5k7rqhQd@OLMyyxVLj_+$#Vc*}Usevp^I(^vH zmDgHc0VMme|K&X?9&lkN{yq_(If)O`oUPW8X}1R5pSVBpfJe0t{sPA(F#`eONTh_) zxeLqHMfJX#?P(@6w4CqRE@Eiza; z;^5)Kk=^5)KDvd9Q<`=sJU8rjjxPmtWMTmzcH={o$U)j=QBuHarp?=}c??!`3d=H$nrJMyr3L-& zA#m?t(NqLM?I3mGgWA_C+0}BWy3-Gj7bR+d+U?n*mN$%5P`ugrB{PeV>jDUn;eVc- zzeMB1mI4?fVJatrNyq|+zn=!AiN~<}eoM#4uSx^K?Iw>P2*r=k`$<3kT00BE_1c(02MRz4(Hq`L^M&xt!pV2 zn+#U3@j~PUR>xIy+P>51iPayk-mqIK_5rlQMSe5&tDkKJk_$i(X&;K(11YGpEc-K= zq4Ln%^j>Zi_+Ae9eYEq_<`D+ddb8_aY!N;)(&EHFAk@Ekg&41ABmOXfWTo)Z&KotA zh*jgDGFYQ^y=m)<_LCWB+v48DTJw*5dwMm_YP0*_{@HANValf?kV-Ic3xsC}#x2h8 z`q5}d8IRmqWk%gR)s~M}(Qas5+`np^jW^oEd-pzERRPMXj$kS17g?H#4^trtKtq;C?;c ztd|%|WP2w2Nzg@)^V}!Gv++QF2!@FP9~DFVISRW6S?eP{H;;8EH;{>X_}NGj^0cg@ z!2@A>-CTcoN02^r6@c~^QUa={0xwK0v4i-tQ9wQq^=q*-{;zJ{Qe%7Qd!&X2>rV@4 z&wznCz*63_vw4>ZF8~%QCM?=vfzW0r_4O^>UA@otm_!N%mH)!ERy&b!n3*E*@?9d^ zu}s^By@FAhG(%?xgJMuMzuJw2&@$-oK>n z=UF}rt%vuaP9fzIFCYN-1&b#r^Cl6RDFIWsEsM|ROf`E?O(cy{BPO2Ie~kT+^kI^i zp>Kbc@C?}3vy-$ZFVX#-cx)Xj&G^ibX{pWggtr(%^?HeQL@Z( zM-430g<{>vT*)jK4aY9(a{lSy{8vxLbP~n1MXwM527ne#SHCC^F_2@o`>c>>KCq9c(4c$VSyMl*y3Nq1s+!DF| z^?d9PipQN(mw^j~{wJ^VOXDCaL$UtwwTpyv8IAwGOg<|NSghkAR1GSNLZ1JwdGJYm zP}t<=5=sNNUEjc=g(y)1n5)ynX(_$1-uGuDR*6Y^Wgg(LT)Jp><5X|}bt z_qMa&QP?l_n+iVS>v%s2Li_;AIeC=Ca^v1jX4*gvB$?H?2%ndnqOaK5-J%7a} zIF{qYa&NfVY}(fmS0OmXA70{znljBOiv5Yod!vFU{D~*3B3Ka{P8?^ zfhlF6o7aNT$qi8(w<}OPw5fqA7HUje*r*Oa(YV%*l0|9FP9KW@U&{VSW{&b0?@y)M zs%4k1Ax;TGYuZ9l;vP5@?3oQsp3)rjBeBvQQ>^B;z5pc=(yHhHtq6|0m(h4envn_j787fizY@V`o(!SSyE7vlMT zbo=Z1c=atz*G!kwzGB;*uPL$Ei|EbZLh8o+1BUMOpnU(uX&OG1MV@|!&HOOeU#t^x zr9=w2ow!SsTuJWT7%Wmt14U_M*3XiWBWHxqCVZI0_g0`}*^&yEG9RK9fHK8e+S^m? zfCNn$JTswUVbiC#>|=wS{t>-MI1aYPLtzO5y|LJ9nm>L6*wpr_m!)A2Fb1RceX&*|5|MwrvOk4+!0p99B9AgP*9D{Yt|x=X}O% zgIG$MrTB=n-!q%ROT|SzH#A$Xm;|ym)0>1KR}Yl0hr-KO&qMrV+0Ej3d@?FcgZ+B3 ztEk16g#2)@x=(ko8k7^Tq$*5pfZHC@O@}`SmzT1(V@x&NkZNM2F#Q-Go7-uf_zKC( zB(lHZ=3@dHaCOf6C!6i8rDL%~XM@rVTJbZL09?ht@r^Z_6x}}atLjvH^4Vk#Ibf(^LiBJFqorm?A=lE zzFmwvp4bT@Nv2V>YQT92X;t9<2s|Ru5#w?wCvlhcHLcsq0TaFLKy(?nzezJ>CECqj zggrI~Hd4LudM(m{L@ezfnpELsRFVFw>fx;CqZtie`$BXRn#Ns%AdoE$-Pf~{9A8rV zf7FbgpKmVzmvn-z(g+&+-ID=v`;6=)itq8oM*+Uz**SMm_{%eP_c0{<%1JGiZS19o z@Gj7$Se~0lsu}w!%;L%~mIAO;AY-2i`9A*ZfFs=X!LTd6nWOZ7BZH2M{l2*I>Xu)0 z`<=;ObglnXcVk!T>e$H?El}ra0WmPZ$YAN0#$?|1v26^(quQre8;k20*dpd4N{i=b zuN=y}_ew9SlE~R{2+Rh^7%PA1H5X(p8%0TpJ=cqa$65XL)$#ign-y!qij3;2>j}I; ziO@O|aYfn&up5F`YtjGw68rD3{OSGNYmBnl?zdwY$=RFsegTZ=kkzRQ`r7ZjQP!H( zp4>)&zf<*N!tI00xzm-ME_a{_I!TbDCr;8E;kCH4LlL-tqLxDuBn-+xgPk37S&S2^ z2QZumkIimwz!c@!r0)j3*(jPIs*V!iLTRl0Cpt_UVNUgGZzdvs0(-yUghJfKr7;=h zD~y?OJ-bWJg;VdZ^r@vlDoeGV&8^--!t1AsIMZ5S440HCVr%uk- z2wV>!W1WCvFB~p$P$$_}|H5>uBeAe>`N1FI8AxM|pq%oNs;ED8x+tb44E) zTj{^fbh@eLi%5AqT?;d>Es5D*Fi{Bpk)q$^iF!!U`r2hHAO_?#!aYmf>G+jHsES4W zgpTKY59d?hsb~F0WE&dUp6lPt;Pm zcbTUqRryw^%{ViNW%Z(o8}dd00H(H-MmQmOiTq{}_rnwOr*Ybo7*}3W-qBT!#s0Ie z-s<1rvvJx_W;ViUD`04%1pra*Yw0BcGe)fDKUK8aF#BwBwMPU;9`!6E(~!043?SZx z13K%z@$$#2%2ovVlgFIPp7Q6(vO)ud)=*%ZSucL2Dh~K4B|%q4KnSpj#n@(0B})!9 z8p*hY@5)NDn^&Pmo;|!>erSYg`LkO?0FB@PLqRvc>4IsUM5O&>rRv|IBRxi(RX(gJ ztQ2;??L~&Mv;aVr5Q@(?y^DGo%pO^~zijld41aA0KKsy_6FeHIn?fNHP-z>$OoWer zjZ5hFQTy*-f7KENRiCE$ZOp4|+Wah|2=n@|W=o}bFM}Y@0e62+_|#fND5cwa3;P{^pEzlJbF1Yq^}>=wy8^^^$I2M_MH(4Dw{F6hm+vrWV5!q;oX z;tTNhz5`-V={ew|bD$?qcF^WPR{L(E%~XG8eJx(DoGzt2G{l8r!QPJ>kpHeOvCv#w zr=SSwMDaUX^*~v%6K%O~i)<^6`{go>a3IdfZ8hFmz&;Y@P%ZygShQZ2DSHd`m5AR= zx$wWU06;GYwXOf(%MFyj{8rPFXD};JCe85Bdp4$YJ2$TzZ7Gr#+SwCvBI1o$QP0(c zy`P51FEBV2HTisM3bHqpmECT@H!Y2-bv2*SoSPoO?wLe{M#zDTy@ujAZ!Izzky~3k zRA1RQIIoC*Mej1PH!sUgtkR0VCNMX(_!b65mo66iM*KQ7xT8t2eev$v#&YdUXKwGm z7okYAqYF&bveHeu6M5p9xheRCTiU8PFeb1_Rht0VVSbm%|1cOVobc8mvqcw!RjrMRM#~=7xibH&Fa5Imc|lZ{eC|R__)OrFg4@X_ ze+kk*_sDNG5^ELmHnZ7Ue?)#6!O)#Nv*Dl2mr#2)w{#i-;}0*_h4A%HidnmclH#;Q zmQbq+P4DS%3}PpPm7K_K3d2s#k~x+PlTul7+kIKol0@`YN1NG=+&PYTS->AdzPv!> zQvzT=)9se*Jr1Yq+C{wbK82gAX`NkbXFZ)4==j4t51{|-v!!$H8@WKA={d>CWRW+g z*`L>9rRucS`vbXu0rzA1#AQ(W?6)}1+oJSF=80Kf_2r~Qm-EJ6bbB3k`80rCv(0d` zvCf3;L2ovYG_TES%6vSuoKfIHC6w;V31!oqHM8-I8AFzcd^+_86!EcCOX|Ta9k1!s z_Vh(EGIIsI3fb&dF$9V8v(sTBC%!#<&KIGF;R+;MyC0~}$gC}}= zR`DbUVc&Bx`lYykFZ4{R{xRaUQkWCGCQlEc;!mf=+nOk$RUg*7 z;kP7CVLEc$CA7@6VFpsp3_t~m)W0aPxjsA3e5U%SfY{tp5BV5jH-5n?YX7*+U+Zs%LGR>U- z!x4Y_|4{gx?ZPJobISy991O znrmrC3otC;#4^&Rg_iK}XH(XX+eUHN0@Oe06hJk}F?`$)KmH^eWz@@N%wEc)%>?Ft z#9QAroDeyfztQ5Qe{m*#R#T%-h*&XvSEn@N$hYRTCMXS|EPwzF3IIysD2waj`vQD{ zv_#^Pgr?s~I*NE=acf@dWVRNWTr(GN0wrL)Z2=`Dr>}&ZDNX|+^Anl{Di%v1Id$_p zK5_H5`RDjJx`BW7hc85|> zHMMsWJ4KTMRHGu+vy*kBEMjz*^K8VtU=bXJYdhdZ-?jTXa$&n)C?QQIZ7ln$qbGlr zS*TYE+ppOrI@AoPP=VI-OXm}FzgXRL)OPvR$a_=SsC<3Jb+>5makX|U!}3lx4tX&L z^C<{9TggZNoeX!P1jX_K5HkEVnQ#s2&c#umzV6s2U-Q;({l+j^?hi7JnQ7&&*oOy9 z(|0asVTWUCiCnjcOnB2pN0DpuTglKq;&SFOQ3pUdye*eT<2()7WKbXp1qq9=bhMWlF-7BHT|i3TEIT77AcjD(v=I207wi-=vyiw5mxgPdTVUC z&h^FEUrXwWs9en2C{ywZp;nvS(Mb$8sBEh-*_d-OEm%~p1b2EpcwUdf<~zmJmaSTO zSX&&GGCEz-M^)G$fBvLC2q@wM$;n4jp+mt0MJFLuJ%c`tSp8$xuP|G81GEd2ci$|M z4XmH{5$j?rqDWoL4vs!}W&!?!rtj=6WKJcE>)?NVske(p;|#>vL|M_$as=mi-n-()a*OU3Okmk0wC<9y7t^D(er-&jEEak2!NnDiOQ99Wx8{S8}=Ng!e0tzj*#T)+%7;aM$ z&H}|o|J1p{IK0Q7JggAwipvHvko6>Epmh4RFRUr}$*2K4dz85o7|3#Bec9SQ4Y*;> zXWjT~f+d)dp_J`sV*!w>B%)#GI_;USp7?0810&3S=WntGZ)+tzhZ+!|=XlQ&@G@~3 z-dw@I1>9n1{+!x^Hz|xC+P#Ab`E@=vY?3%Bc!Po~e&&&)Qp85!I|U<-fCXy*wMa&t zgDk!l;gk;$taOCV$&60z+}_$ykz=Ea*)wJQ3-M|p*EK(cvtIre0Pta~(95J7zoxBN zS(yE^3?>88AL0Wfuou$BM{lR1hkrRibz=+I9ccwd`ZC*{NNqL)3pCcw^ygMmrG^Yp zn5f}Xf>%gncC=Yq96;rnfp4FQL#{!Y*->e82rHgY4Zwy{`JH}b9*qr^VA{%~Z}jtp z_t$PlS6}5{NtTqXHN?uI8ut8rOaD#F1C^ls73S=b_yI#iZDOGz3#^L@YheGd>L;<( z)U=iYj;`{>VDNzIxcjbTk-X3keXR8Xbc`A$o5# zKGSk-7YcoBYuAFFSCjGi;7b<;n-*`USs)IX z=0q6WZ=L!)PkYtZE-6)azhXV|+?IVGTOmMCHjhkBjfy@k1>?yFO3u!)@cl{fFAXnRYsWk)kpT?X{_$J=|?g@Q}+kFw|%n!;Zo}|HE@j=SFMvT8v`6Y zNO;tXN^036nOB2%=KzxB?n~NQ1K8IO*UE{;Xy;N^ZNI#P+hRZOaHATz9(=)w=QwV# z`z3+P>9b?l-@$@P3<;w@O1BdKh+H;jo#_%rr!ute{|YX4g5}n?O7Mq^01S5;+lABE+7`&_?mR_z7k|Ja#8h{!~j)| zbBX;*fsbUak_!kXU%HfJ2J+G7;inu#uRjMb|8a){=^))y236LDZ$$q3LRlat1D)%7K0!q5hT5V1j3qHc7MG9 z_)Q=yQ>rs>3%l=vu$#VVd$&IgO}Za#?aN!xY>-<3PhzS&q!N<=1Q7VJBfHjug^4|) z*fW^;%3}P7X#W3d;tUs3;`O&>;NKZBMR8au6>7?QriJ@gBaorz-+`pUWOP73DJL=M z(33uT6Gz@Sv40F6bN|H=lpcO z^AJl}&=TIjdevuDQ!w0K*6oZ2JBOhb31q!XDArFyKpz!I$p4|;c}@^bX{>AXdt7Bm zaLTk?c%h@%xq02reu~;t@$bv`b3i(P=g}~ywgSFpM;}b$zAD+=I!7`V~}ARB(Wx0C(EAq@?GuxOL9X+ffbkn3+Op0*80TqmpAq~EXmv%cq36celXmRz z%0(!oMp&2?`W)ALA&#|fu)MFp{V~~zIIixOxY^YtO5^FSox8v$#d0*{qk0Z)pNTt0QVZ^$`4vImEB>;Lo2!7K05TpY-sl#sWBz_W-aDIV`Ksabi zvpa#93Svo!70W*Ydh)Qzm{0?CU`y;T^ITg-J9nfWeZ-sbw)G@W?$Eomf%Bg2frfh5 zRm1{|E0+(4zXy){$}uC3%Y-mSA2-^I>Tw|gQx|7TDli_hB>``)Q^aZ`LJC2V3U$SABP}T)%}9g2pF9dT}aC~!rFFgkl1J$ z`^z{Arn3On-m%}r}TGF8KQe*OjSJ=T|caa_E;v89A{t@$yT^(G9=N9F?^kT*#s3qhJq!IH5|AhnqFd z0B&^gm3w;YbMNUKU>naBAO@fbz zqw=n!@--}o5;k6DvTW9pw)IJVz;X}ncbPVrmH>4x);8cx;q3UyiML1PWp%bxSiS|^ zC5!kc4qw%NSOGQ*Kcd#&$30=lDvs#*4W4q0u8E02U)7d=!W7+NouEyuF1dyH$D@G& zaFaxo9Ex|ZXA5y{eZT*i*dP~INSMAi@mvEX@q5i<&o&#sM}Df?Og8n8Ku4vOux=T% zeuw~z1hR}ZNwTn8KsQHKLwe2>p^K`YWUJEdVEl|mO21Bov!D0D$qPoOv=vJJ`)|%_ z>l%`eexY7t{BlVKP!`a^U@nM?#9OC*t76My_E_<16vCz1x_#82qj2PkWiMWgF8bM9 z(1t4VdHcJ;B~;Q%x01k_gQ0>u2*OjuEWNOGX#4}+N?Gb5;+NQMqp}Puqw2HnkYuKA zzKFWGHc&K>gwVgI1Sc9OT1s6fq=>$gZU!!xsilA$fF`kLdGoX*^t}ao@+^WBpk>`8 z4v_~gK|c2rCq#DZ+H)$3v~Hoi=)=1D==e3P zpKrRQ+>O^cyTuWJ%2}__0Z9SM_z9rptd*;-9uC1tDw4+A!=+K%8~M&+Zk#13hY$Y$ zo-8$*8dD5@}XDi19RjK6T^J~DIXbF5w&l?JLHMrf0 zLv0{7*G!==o|B%$V!a=EtVHdMwXLtmO~vl}P6;S(R2Q>*kTJK~!}gloxj)m|_LYK{ zl(f1cB=EON&wVFwK?MGn^nWuh@f95SHatPs(jcwSY#Dnl1@_gkOJ5=f`%s$ZHljRH0 z+c%lrb=Gi&N&1>^L_}#m>=U=(oT^vTA&3!xXNyqi$pdW1BDJ#^{h|2tZc{t^vag3& zAD7*8C`chNF|27itjBUo^CCDyEpJLX3&u+(L;YeeMwnXEoyN(ytoEabcl$lSgx~Ltatn}b$@j_yyMrBb03)shJE*$;Mw=;mZd&8e>IzE+4WIoH zCSZE7WthNUL$|Y#m!Hn?x7V1CK}V`KwW2D$-7&ODy5Cj;!_tTOOo1Mm%(RUt)#$@3 zhurA)t<7qik%%1Et+N1?R#hdBB#LdQ7{%-C zn$(`5e0eFh(#c*hvF>WT*07fk$N_631?W>kfjySN8^XC9diiOd#s?4tybICF;wBjp zIPzilX3{j%4u7blhq)tnaOBZ_`h_JqHXuI7SuIlNTgBk9{HIS&3|SEPfrvcE<@}E` zKk$y*nzsqZ{J{uWW9;#n=de&&h>m#A#q)#zRonr(?mDOYU&h&aQWD;?Z(22wY?t$U3qo`?{+amA$^TkxL+Ex2dh`q7iR&TPd0Ymwzo#b? zP$#t=elB5?k$#uE$K>C$YZbYUX_JgnXA`oF_Ifz4H7LEOW~{Gww&3s=wH4+j8*TU| zSX%LtJWqhr-xGNSe{;(16kxnak6RnZ{0qZ^kJI5X*It_YuynSpi(^-}Lolr{)#z_~ zw!(J-8%7Ybo^c3(mED`Xz8xecP35a6M8HarxRn%+NJBE;dw>>Y2T&;jzRd4FSDO3T zt*y+zXCtZQ0bP0yf6HRpD|WmzP;DR^-g^}{z~0x~z4j8m zucTe%k&S9Nt-?Jb^gYW1w6!Y3AUZ0Jcq;pJ)Exz%7k+mUOm6%ApjjSmflfKwBo6`B zhNb@$NHTJ>guaj9S{@DX)!6)b-Shav=DNKWy(V00k(D!v?PAR0f0vDNq*#mYmUp6> z76KxbFDw5U{{qx{BRj(>?|C`82ICKbfLxoldov-M?4Xl+3;I4GzLHyPOzYw7{WQST zPNYcx5onA%MAO9??41Po*1zW(Y%Zzn06-lUp{s<3!_9vv9HBjT02On0Hf$}NP;wF) zP<`2p3}A^~1YbvOh{ePMx$!JGUPX-tbBzp3mDZMY;}h;sQ->!p97GA)9a|tF(Gh{1$xk7 zUw?ELkT({Xw!KIr);kTRb1b|UL`r2_`a+&UFVCdJ)1T#fdh;71EQl9790Br0m_`$x z9|ZANuchFci8GNZ{XbP=+uXSJRe(;V5laQz$u18#?X*9}x7cIEbnr%<=1cX3EIu7$ zhHW6pe5M(&qEtsqRa>?)*{O;OJT+YUhG5{km|YI7I@JL_3Hwao9aXneiSA~a* z|Lp@c-oMNyeAEuUz{F?kuou3x#C*gU?lon!RC1s37gW^0Frc`lqQWH&(J4NoZg3m8 z;Lin#8Q+cFPD7MCzj}#|ws7b@?D9Q4dVjS4dpco=4yX5SSH=A@U@yqPdp@?g?qeia zH=Tt_9)G=6C2QIPsi-QipnK(mc0xXIN;j$WLf@n8eYvMk;*H-Q4tK%(3$CN}NGgO8n}fD~+>?<3UzvsrMf*J~%i;VKQHbF%TPalFi=#sgj)(P#SM^0Q=Tr>4kJVw8X3iWsP|e8tj}NjlMdWp z@2+M4HQu~3!=bZpjh;;DIDk&X}=c8~kn)FWWH z2KL1w^rA5&1@@^X%MjZ7;u(kH=YhH2pJPFQe=hn>tZd5RC5cfGYis8s9PKaxi*}-s6*W zRA^PwR=y^5Z){!(4D9-KC;0~;b*ploznFOaU`bJ_7U?qAi#mTo!&rIECRL$_y@yI27x2?W+zqDBD5~KCVYKFZLK+>ABC(Kj zeAll)KMgIlAG`r^rS{loBrGLtzhHY8$)<_S<(Dpkr(Ym@@vnQ&rS@FC*>2@XCH}M+an74WcRDcoQ+a3@A z9tYhl5$z7bMdTvD2r&jztBuo37?*k~wcU9GK2-)MTFS-lux-mIRYUuGUCI~V$?s#< z?1qAWb(?ZLm(N>%S%y10COdaq_Tm5c^%ooIxpR=`3e4C|@O5wY+eLik&XVi5oT7oe zmxH)Jd*5eo@!7t`x8!K=-+zJ-Sz)B_V$)s1pW~CDU$=q^&ABvf6S|?TOMB-RIm@CoFg>mjIQE)?+A1_3s6zmFU_oW&BqyMz1mY*IcP_2knjq5 zqw~JK(cVsmzc7*EvTT2rvpeqhg)W=%TOZ^>f`rD4|7Z5fq*2D^lpCttIg#ictgqZ$P@ru6P#f$x#KfnfTZj~LG6U_d-kE~`;kU_X)`H5so@?C zWmb!7x|xk@0L~0JFall*@ltyiL^)@3m4MqC7(7H0sH!WidId1#f#6R{Q&A!XzO1IAcIx;$k66dumt6lpUw@nL2MvqJ5^kbOVZ<^2jt5-njy|2@`07}0w z;M%I1$FCoLy`8xp8Tk)bFr;7aJeQ9KK6p=O$U0-&JYYy8woV*>b+FB?xLX`=pirYM z5K$BA(u)+jR{?O2r$c_Qvl?M{=Ar{yQ!UVsVn4k@0!b?_lA;dVz9uaQUgBH8Oz(Sb zrEs;&Ey>_ex8&!N{PmQjp+-Hlh|OA&wvDai#GpU=^-B70V0*LF=^bi+Nhe_o|azZ%~ZZ1$}LTmWt4aoB1 zPgccm$EwYU+jrdBaQFxQfn5gd(gM`Y*Ro1n&Zi?j=(>T3kmf94vdhf?AuS8>$Va#P zGL5F+VHpxdsCUa}+RqavXCobI-@B;WJbMphpK2%6t=XvKWWE|ruvREgM+|V=i6;;O zx$g=7^`$XWn0fu!gF=Xe9cMB8Z_SelD>&o&{1XFS`|nInK3BXlaeD*rc;R-#osyIS zWv&>~^TLIyBB6oDX+#>3<_0+2C4u2zK^wmHXXDD9_)kmLYJ!0SzM|%G9{pi)`X$uf zW}|%%#LgyK7m(4{V&?x_0KEDq56tk|0YNY~B(Sr|>WVz-pO3A##}$JCT}5P7DY+@W z#gJv>pA5>$|E3WO2tV7G^SuymB?tY`ooKcN3!vaQMnBNk-WATF{-$#}FyzgtJ8M^; zUK6KWSG)}6**+rZ&?o@PK3??uN{Q)#+bDP9i1W&j)oaU5d0bIWJ_9T5ac!qc?x66Q z$KUSZ`nYY94qfN_dpTFr8OW~A?}LD;Yty-BA)-be5Z3S#t2Io%q+cAbnGj1t$|qFR z9o?8B7OA^KjCYL=-!p}w(dkC^G6Nd%_I=1))PC0w5}ZZGJxfK)jP4Fwa@b-SYBw?% zdz9B-<`*B2dOn(N;mcTm%Do)rIvfXRNFX&1h`?>Rzuj~Wx)$p13nrDlS8-jwq@e@n zNIj_|8or==8~1h*Ih?w*8K7rYkGlwlTWAwLKc5}~dfz3y`kM&^Q|@C%1VAp_$wnw6zG~W4O+^ z>i?NY?oXf^Puc~+fDM$VgRNBpOZj{2cMP~gCqWAX4 z7>%$ux8@a&_B(pt``KSt;r+sR-$N;jdpY>|pyvPiN)9ohd*>mVST3wMo)){`B(&eX z1?zZJ-4u9NZ|~j1rdZYq4R$?swf}<6(#ex%7r{kh%U@kT)&kWuAszS%oJts=*OcL9 zaZwK<5DZw%1IFHXgFplP6JiL^dk8+SgM$D?8X+gE4172hXh!WeqIO>}$I9?Nry$*S zQ#f)RuH{P7RwA3v9f<-w>{PSzom;>(i&^l{E0(&Xp4A-*q-@{W1oE3K;1zb{&n28dSC2$N+6auXe0}e4b z)KLJ?5c*>@9K#I^)W;uU_Z`enquTUxr>mNq z1{0_puF-M7j${rs!dxxo3EelGodF1TvjV;Zpo;s{5f1pyCuRp=HDZ?s#IA4f?h|-p zGd|Mq^4hDa@Bh!c4ZE?O&x&XZ_ptZGYK4$9F4~{%R!}G1leCBx`dtNUS|K zL-7J5s4W@%mhXg1!}a4PD%!t&Qn%f_oquRajn3@C*)`o&K9o7V6DwzVMEhjVdDJ1fjhr#@=lp#@4EBqi=CCQ>73>R(>QKPNM&_Jpe5G`n4wegeC`FYEPJ{|vwS>$-`fuRSp3927qOv|NC3T3G-0 zA{K`|+tQy1yqE$ShWt8ny&5~)%ITb@^+x$w0)f&om;P8B)@}=Wzy59BwUfZ1vqw87 za2lB8J(&*l#(V}Id8SyQ0C(2amzkz3EqG&Ed0Jq1)$|&>4_|NIe=5|n=3?siFV0fI z{As5DLW^gs|B-b4C;Hd(SM-S~GQhzb>HgF2|2Usww0nL^;x@1eaB)=+Clj+$fF@H( z-fqP??~QMT$KI-#m;QC*&6vkp&8699G3)Bq0*kFZXINw=b9OVaed(3(3kS|IZ)CM? zJdnW&%t8MveBuK21uiYj)_a{Fnw0OErMzMN?d$QoPwkhOwcP&p+t>P)4tHlYw-pPN z^oJ=uc$Sl>pv@fZH~ZqxSvdhF@F1s=oZawpr^-#l{IIOGG=T%QXjtwPhIg-F@k@uIlr?J->Ia zpEUQ*=4g|XYn4Gez&aHr*;t$u3oODPmc2Ku)2Og|xjc%w;q!Zz+zY)*3{7V8bK4;& zYV82FZ+8?v)`J|G1w4I0fWdKg|2b#iaazCv;|?(W-q}$o&Y}Q5d@BRk^jL7#{kbCK zSgkyu;=DV+or2)AxCBgq-nj5=@n^`%T#V+xBGEkW4lCqrE)LMv#f;AvD__cQ@Eg3`~x| zW+h9mofSXCq5|M)9|ez(#X?-sxB%Go8};sJ?2abp(Y!lyi>k)|{M*Z$c{e1-K4ky` MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..09d559fae262a06d0a80176a3966582f84911105 GIT binary patch literal 6847 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 + + + + + + + + + \ 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 0000000000000000000000000000000000000000..c1a0c584108978e3128c4f155d46a8f891a3e91e GIT binary patch literal 405 zcmV;G0c!qlYC!?JG`lAet3|4Sa62O0k|3?_*9M*kcU|{5ji;@KXe`J(%SpOf+Cj$^PKtzz8 zft7`cVNsVAgKOemxHt@8HGqSinL$d7lR-?FgCW9Op5gfUHw;le3Jm^P2jOa90ILCx z=Hd)|Jgf{7BAg7&%!~|5vOEkQKK){7nSKSX1_rPi5bLkVaP7`VhS&f_hK+lkFf`=q zF|^IN!m#tmbGRB9z-oYrju6AGho2c1cUmzNcbsL|yU>L}-+vp}73ct~0U*G{#K52; z&kOcOd8#&pciMiq95TRZ01U7&Gcxe=vNF8=fN47j5Hx@gpeQLb{AV~ud1eAJD9O$M z5JzmhabT_e0000GWMx7}H8esuLP0k(L`5(%Lo_xx*I#m<00000NkvXXu0mjf9)P5- literal 0 HcmV?d00001 diff --git a/Web App/public/favicon-32.png b/Web App/public/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..f95daa75db14f90c0a8752286ff46224e9c45415 GIT binary patch literal 905 zcmV;419tq0P)>)|ZSpOot2Oad|-ncM~g zj+07VTn8Xf9CaWJ>pz_cuUDt;6)78j55{~#4KF~e>ij<7!Vw^pOC9q_HCsr++j@u8LgrwQ&VF*x-N6|e4 z3bY?kaeWQECM$5_Yz@v|YY^AjV^{*I$#Wrhk>O5R6KWeea3d!I97o8^dyUV{-FQ;C zT8Pu^)}yMn&AQ4yk_D!E$dR&Xw$MIT_#QL7+;C!lh|u4Bpd3+4r{PR`IQsf6n7g(_ z(gEa=EU+lZ12K`_=<7El>%vL&KJh!ZtOf(A;@fr_rtSl6E^KD7uOAiWC8wg z1qZsL(a?p$JD-HpzA$JKbmd=>bGaV6{1piFn}FOa^@5;OBnxO(`UnZM_l;=y)P);4 z%OQ8+kbS8RPpeF*EsYi8wx&ISzT%s8)zC;5;5fil?t~vb1Hx^WcWkjRo^<#rg6FuS z;N((REWrHO+d_;~Bnu42#hJs7jRB~5WkO|5E7DSeuq$y6Y8yKcv*V!{Cxt%}_?@G> zgRw1P77k@q;rd;JI4*@z5g>wa0X2W;pBBSoR0KxCzaXG(2WXy fL^Co%H8eOyMnN?+2Y_-w00000NkvXXu0mjfs56|R literal 0 HcmV?d00001 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 @@ + + +