Initial commit
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.env
|
||||||
|
*.exe
|
||||||
|
/tmp/
|
||||||
|
/bin/
|
||||||
@@ -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"]
|
||||||
@@ -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 <token>) -> { 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=<long random value>
|
||||||
|
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 <email> <pw> "Name" # create an app login
|
||||||
|
node scripts/set-role.mjs <email> user|admin # promote/demote
|
||||||
|
node scripts/backfill-car-owners.mjs # one-off: assign owner to legacy cars
|
||||||
|
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.
|
||||||
@@ -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}"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module carcontrol/api
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 <token>" and a raw token.
|
||||||
|
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||||
|
return strings.TrimSpace(after)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||||
|
<rect width="256" height="256" rx="56" fill="#1E40AF"></rect>
|
||||||
|
<svg x="53" y="53" width="150" height="150" viewBox="0 0 48 48">
|
||||||
|
<g transform="translate(7 0) skewX(-13)">
|
||||||
|
<rect x="9" y="16" width="6" height="16" rx="3" fill="#ffffff" opacity="0.55"></rect>
|
||||||
|
<rect x="19" y="12" width="6" height="24" rx="3" fill="#ffffff" opacity="0.8"></rect>
|
||||||
|
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 550 B |
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#2563eb" />
|
||||||
|
<title>DriverVault · API Server</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-o_I931vi.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-DfVJ8vbT.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#2563eb" />
|
||||||
|
<title>DriverVault · API Server</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||||
|
<rect width="256" height="256" rx="56" fill="#1E40AF"></rect>
|
||||||
|
<svg x="53" y="53" width="150" height="150" viewBox="0 0 48 48">
|
||||||
|
<g transform="translate(7 0) skewX(-13)">
|
||||||
|
<rect x="9" y="16" width="6" height="16" rx="3" fill="#ffffff" opacity="0.55"></rect>
|
||||||
|
<rect x="19" y="12" width="6" height="24" rx="3" fill="#ffffff" opacity="0.8"></rect>
|
||||||
|
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 550 B |
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||||
|
import { theme, toggleTheme } from "./theme";
|
||||||
|
import EndpointTable from "./components/EndpointTable.vue";
|
||||||
|
|
||||||
|
// Live health poll against this server.
|
||||||
|
const status = ref("checking"); // checking | ok | error | unreachable
|
||||||
|
const httpStatus = ref(null);
|
||||||
|
const latency = ref(null);
|
||||||
|
const checkedAt = ref(null);
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
const started = performance.now();
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/health");
|
||||||
|
latency.value = Math.round(performance.now() - started);
|
||||||
|
httpStatus.value = r.status;
|
||||||
|
status.value = r.ok ? "ok" : "error";
|
||||||
|
} catch {
|
||||||
|
latency.value = null;
|
||||||
|
httpStatus.value = null;
|
||||||
|
status.value = "unreachable";
|
||||||
|
}
|
||||||
|
checkedAt.value = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
check();
|
||||||
|
timer = setInterval(check, 10000);
|
||||||
|
});
|
||||||
|
onUnmounted(() => clearInterval(timer));
|
||||||
|
|
||||||
|
const badge = {
|
||||||
|
checking: { label: "checking", cls: "bg-sunken text-muted" },
|
||||||
|
ok: { label: "operational", cls: "bg-success-soft text-success" },
|
||||||
|
error: { label: "error", cls: "bg-danger-soft text-danger" },
|
||||||
|
unreachable: { label: "unreachable", cls: "bg-danger-soft text-danger" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logo bar fills — flip for legibility on the dark shell.
|
||||||
|
const barFills = computed(() =>
|
||||||
|
theme.value === "dark"
|
||||||
|
? ["#60a5fa", "#93c5fd", "#ffffff"]
|
||||||
|
: ["var(--brand-700)", "var(--brand-500)", "var(--brand-400)"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The DriverVault REST surface, grouped by resource. Mirrors the routes registered
|
||||||
|
// in internal/api/server.go.
|
||||||
|
const publicApi = [
|
||||||
|
{ method: "GET", path: "/api/health", desc: "Liveness probe (no auth)" },
|
||||||
|
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a JWT" },
|
||||||
|
{ method: "GET", path: "/api/auth/me", desc: "Identity of the bearer token" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const carsApi = [
|
||||||
|
{ 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)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const serviceApi = [
|
||||||
|
{ 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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const partsApi = [
|
||||||
|
{ 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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const accountApi = [
|
||||||
|
{ 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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sessionsApi = [
|
||||||
|
{ method: "GET", path: "/api/sessions", desc: "List active sessions (devices)" },
|
||||||
|
{ method: "DELETE", path: "/api/sessions/{id}", desc: "Revoke one session" },
|
||||||
|
{ method: "DELETE", path: "/api/sessions", desc: "Revoke all other sessions" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminApi = [
|
||||||
|
{ method: "GET", path: "/api/admin/users", desc: "List users" },
|
||||||
|
{ method: "POST", path: "/api/admin/users", desc: "Create a user" },
|
||||||
|
{ method: "PATCH", path: "/api/admin/users/{id}", desc: "Update name / role" },
|
||||||
|
{ method: "POST", path: "/api/admin/users/{id}/password", desc: "Reset a user's password" },
|
||||||
|
{ method: "DELETE", path: "/api/admin/users/{id}", desc: "Delete a user" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto flex max-w-3xl flex-col gap-6 px-6 pt-12 pb-16">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="inline-flex items-center gap-2.5 select-none">
|
||||||
|
<svg class="h-8 w-8 shrink-0" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
||||||
|
<g transform="translate(7 0) skewX(-13)">
|
||||||
|
<rect x="9" y="16" width="6" height="16" rx="3" :fill="barFills[0]" />
|
||||||
|
<rect x="19" y="12" width="6" height="24" rx="3" :fill="barFills[1]" />
|
||||||
|
<rect x="29" y="8" width="6" height="32" rx="3" :fill="barFills[2]" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span class="text-2xl leading-none font-extrabold tracking-[-0.03em] italic">
|
||||||
|
<span class="text-strong">Driver</span><span class="text-brandtext">Vault</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="eyebrow mt-1.5">API server</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
class="dh-btn-ghost"
|
||||||
|
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
||||||
|
@click="toggleTheme"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="theme === 'dark'"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
class="h-4 w-4"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
|
||||||
|
</svg>
|
||||||
|
Theme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="dh-card">
|
||||||
|
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||||
|
<div class="text-base font-bold tracking-[-0.02em] text-strong">Status</div>
|
||||||
|
<span
|
||||||
|
class="data inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-xs font-medium"
|
||||||
|
:class="badge[status].cls"
|
||||||
|
>
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
||||||
|
{{ badge[status].label }}<template v-if="status === 'error'"> {{ httpStatus }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="data flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4 text-xs text-muted">
|
||||||
|
<span>GET /api/health</span>
|
||||||
|
<span>{{ latency !== null ? latency + "ms" : "—" }}</span>
|
||||||
|
<span>{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EndpointTable title="Public" auth="No auth" :endpoints="publicApi" />
|
||||||
|
<EndpointTable title="Cars" auth="Bearer JWT" :endpoints="carsApi" />
|
||||||
|
<EndpointTable title="Service records" auth="Bearer JWT" :endpoints="serviceApi" />
|
||||||
|
<EndpointTable title="Parts" auth="Bearer JWT" :endpoints="partsApi" />
|
||||||
|
<EndpointTable title="Account" auth="Bearer JWT" :endpoints="accountApi" />
|
||||||
|
<EndpointTable title="Sessions" auth="Bearer JWT" :endpoints="sessionsApi" />
|
||||||
|
<EndpointTable title="Admin" auth="Admin JWT" :endpoints="adminApi" />
|
||||||
|
|
||||||
|
<p class="eyebrow text-center">
|
||||||
|
DriverVault — car maintenance & service tracker.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
title: String,
|
||||||
|
auth: String,
|
||||||
|
endpoints: Array, // [{ method, path, desc }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const methodClass = {
|
||||||
|
GET: "text-success",
|
||||||
|
POST: "text-brandtext",
|
||||||
|
PATCH: "text-warning",
|
||||||
|
DELETE: "text-danger",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dh-card overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||||
|
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ title }}</div>
|
||||||
|
<span class="eyebrow">{{ auth }}</span>
|
||||||
|
</div>
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
|
||||||
|
<th>Endpoint</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="e in endpoints"
|
||||||
|
:key="e.method + e.path"
|
||||||
|
class="transition-colors hover:bg-sunken"
|
||||||
|
>
|
||||||
|
<td class="data border-t border-subtle px-5 py-2.5 text-xs whitespace-nowrap">
|
||||||
|
<span class="font-semibold" :class="methodClass[e.method]">{{ e.method }}</span>
|
||||||
|
<span class="text-strong"> {{ e.path }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="border-t border-subtle px-5 py-2.5 text-body">{{ e.desc }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./style.css";
|
||||||
|
import "./theme";
|
||||||
|
|
||||||
|
createApp(App).mount("#app");
|
||||||
@@ -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 <html>. */
|
||||||
|
@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);
|
||||||
|
}
|
||||||
@@ -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 <html>), 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);
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 <ownerUserId>
|
||||||
|
//
|
||||||
|
// Defaults <ownerUserId> 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);
|
||||||
|
});
|
||||||
@@ -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 <email> <password> [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 <email> <password> [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);
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
@@ -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 <email> <user|admin>
|
||||||
|
|
||||||
|
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 <email> <user|admin>");
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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=
|
||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
@@ -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=
|
||||||
@@ -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:
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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\\
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
@@ -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 <device> --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
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_const_constructors: true
|
||||||
@@ -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
|
||||||
@@ -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 = "../.."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Biometric (fingerprint / face) sign-in via local_auth. -->
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||||
|
<application
|
||||||
|
android:label="DriverVault"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -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()
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
|
android:inset="16%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1E40AF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -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<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,254 @@
|
|||||||
|
import "dart:convert";
|
||||||
|
import "package:http/http.dart" as http;
|
||||||
|
import "package:shared_preferences/shared_preferences.dart";
|
||||||
|
|
||||||
|
import "config.dart";
|
||||||
|
import "models.dart";
|
||||||
|
|
||||||
|
/// Thrown when the API Server returns a non-2xx response.
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final int status;
|
||||||
|
final String message;
|
||||||
|
ApiException(this.status, this.message);
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single client for the Car Control API Server. Holds the bearer token and
|
||||||
|
/// attaches it to every request. On 401 it calls [onUnauthorized] so the app can
|
||||||
|
/// route back to login.
|
||||||
|
class ApiClient {
|
||||||
|
String? token;
|
||||||
|
void Function()? onUnauthorized;
|
||||||
|
|
||||||
|
static const _serverKey = "cc_server_url";
|
||||||
|
|
||||||
|
/// The effective API base URL. Defaults to [kDefaultApiBase]; a saved override
|
||||||
|
/// (login screen "Server settings") replaces it via [loadServerUrl].
|
||||||
|
String baseUrl = kDefaultApiBase;
|
||||||
|
|
||||||
|
/// Loads a saved server-URL override, if any. Call before the first request.
|
||||||
|
Future<void> 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<String> 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<void> 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<String, String> get _headers => {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
if (token != null) "Authorization": "Bearer $token",
|
||||||
|
};
|
||||||
|
|
||||||
|
Uri _uri(String path) => Uri.parse("$baseUrl$path");
|
||||||
|
|
||||||
|
Future<dynamic> _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<String, dynamic>.from(data["user"])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- cars ---
|
||||||
|
Future<List<Car>> listCars() async {
|
||||||
|
final data = await _send("GET", "/cars") as List;
|
||||||
|
return data.map((e) => Car.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Car> getCar(String id) async {
|
||||||
|
final data = await _send("GET", "/cars/$id");
|
||||||
|
return Car.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Car> createCar(Map<String, dynamic> body) async {
|
||||||
|
final data = await _send("POST", "/cars", body: body);
|
||||||
|
return Car.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Car> updateCar(String id, Map<String, dynamic> body) async {
|
||||||
|
final data = await _send("PATCH", "/cars/$id", body: body);
|
||||||
|
return Car.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteCar(String id) => _send("DELETE", "/cars/$id");
|
||||||
|
|
||||||
|
// --- sharing (owner-only) ---
|
||||||
|
Future<List<CarShare>> listCarShares(String carId) async {
|
||||||
|
final data = await _send("GET", "/cars/$carId/shares") as List;
|
||||||
|
return data.map((e) => CarShare.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CarShare> 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<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeCarShare(String carId, String userId) =>
|
||||||
|
_send("DELETE", "/cars/$carId/shares/$userId");
|
||||||
|
|
||||||
|
// --- admin: user management (admin role only) ---
|
||||||
|
Future<List<AdminUser>> listUsers() async {
|
||||||
|
final data = await _send("GET", "/admin/users") as List;
|
||||||
|
return data.map((e) => AdminUser.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AdminUser> 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<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AdminUser> updateUser(String id, {String? name, String? role}) async {
|
||||||
|
final body = <String, dynamic>{};
|
||||||
|
if (name != null) body["name"] = name;
|
||||||
|
if (role != null) body["role"] = role;
|
||||||
|
final data = await _send("PATCH", "/admin/users/$id", body: body);
|
||||||
|
return AdminUser.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setUserPassword(String id, String newPassword) =>
|
||||||
|
_send("POST", "/admin/users/$id/password", body: {"newPassword": newPassword});
|
||||||
|
|
||||||
|
Future<void> deleteUser(String id) => _send("DELETE", "/admin/users/$id");
|
||||||
|
|
||||||
|
// --- service records ---
|
||||||
|
Future<List<ServiceRecord>> listCarServices(String carId) async {
|
||||||
|
final data = await _send("GET", "/cars/$carId/service-records") as List;
|
||||||
|
return data.map((e) => ServiceRecord.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ServiceRecord> createService(Map<String, dynamic> body) async {
|
||||||
|
final data = await _send("POST", "/service-records", body: body);
|
||||||
|
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ServiceRecord> updateService(String id, Map<String, dynamic> body) async {
|
||||||
|
final data = await _send("PATCH", "/service-records/$id", body: body);
|
||||||
|
return ServiceRecord.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteService(String id) => _send("DELETE", "/service-records/$id");
|
||||||
|
|
||||||
|
// --- parts ---
|
||||||
|
Future<List<Part>> listCarParts(String carId) async {
|
||||||
|
final data = await _send("GET", "/cars/$carId/parts") as List;
|
||||||
|
return data.map((e) => Part.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Part> createPart(Map<String, dynamic> body) async {
|
||||||
|
final data = await _send("POST", "/parts", body: body);
|
||||||
|
return Part.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Part> updatePart(String id, Map<String, dynamic> body) async {
|
||||||
|
final data = await _send("PATCH", "/parts/$id", body: body);
|
||||||
|
return Part.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deletePart(String id) => _send("DELETE", "/parts/$id");
|
||||||
|
|
||||||
|
// --- settings: profile / account ---
|
||||||
|
Future<UserProfile> getMe() async {
|
||||||
|
final data = await _send("GET", "/me");
|
||||||
|
return UserProfile.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UserProfile> updateMe(Map<String, dynamic> patch) async {
|
||||||
|
final data = await _send("PATCH", "/me", body: patch);
|
||||||
|
return UserProfile.fromJson(Map<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> changePassword(String oldPassword, String newPassword) => _send(
|
||||||
|
"POST",
|
||||||
|
"/me/password",
|
||||||
|
body: {"oldPassword": oldPassword, "newPassword": newPassword},
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> requestVerification() => _send("POST", "/me/verify/request");
|
||||||
|
|
||||||
|
// --- settings: avatar ---
|
||||||
|
Future<UserProfile> uploadAvatar(List<int> 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<String, dynamic>.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>?> 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<void> deleteAvatar() => _send("DELETE", "/me/avatar");
|
||||||
|
|
||||||
|
// --- settings: sessions ---
|
||||||
|
Future<List<Session>> listSessions() async {
|
||||||
|
final data = await _send("GET", "/sessions") as List;
|
||||||
|
return data.map((e) => Session.fromJson(Map<String, dynamic>.from(e))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> revokeSession(String id) => _send("DELETE", "/sessions/$id");
|
||||||
|
Future<void> revokeOtherSessions() => _send("DELETE", "/sessions");
|
||||||
|
|
||||||
|
// --- settings: account deletion ---
|
||||||
|
Future<DateTime?> requestAccountDeletion(String confirmEmail) async {
|
||||||
|
final data = await _send("POST", "/me/delete", body: {"confirmEmail": confirmEmail});
|
||||||
|
final at = (data is Map) ? data["eligibleAt"] : null;
|
||||||
|
return at == null ? null : DateTime.tryParse(at.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelAccountDeletion() => _send("POST", "/me/delete/cancel");
|
||||||
|
Future<void> finalizeAccountDeletion() => _send("DELETE", "/me");
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> _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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> 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<void> logout() async {
|
||||||
|
api.token = null;
|
||||||
|
user = null;
|
||||||
|
locked = false;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
|
await prefs.remove(_userKey);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<bool> 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<List<BiometricType>> availableTypes() async {
|
||||||
|
try {
|
||||||
|
return await _auth.getAvailableBiometrics();
|
||||||
|
} on PlatformException {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> hasFace() async =>
|
||||||
|
(await availableTypes()).contains(BiometricType.face);
|
||||||
|
|
||||||
|
Future<bool> hasFingerprint() async =>
|
||||||
|
(await availableTypes()).contains(BiometricType.fingerprint);
|
||||||
|
|
||||||
|
/// The email biometric login was enabled for, or null if it's off.
|
||||||
|
Future<String?> enabledEmail() async {
|
||||||
|
final v = await _store.read(key: _emailKey);
|
||||||
|
enabledCached = v != null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> 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<void> enable(String email, String password) async {
|
||||||
|
await _store.write(key: _emailKey, value: email);
|
||||||
|
await _store.write(key: _passwordKey, value: password);
|
||||||
|
enabledCached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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();
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void> 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<CarControlApp> createState() => _CarControlAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CarControlAppState extends State<CarControlApp> 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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> j) {
|
||||||
|
final user = Map<String, dynamic>.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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> j) => Session(
|
||||||
|
id: _asStr(j["id"]),
|
||||||
|
deviceLabel: _asStr(j["deviceLabel"]),
|
||||||
|
ip: _asStr(j["ip"]),
|
||||||
|
current: _asBool(j["current"]),
|
||||||
|
created: DateTime.tryParse(_asStr(j["created"]))?.toLocal(),
|
||||||
|
expiresAt: DateTime.tryParse(_asStr(j["expiresAt"]))?.toLocal(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AdminUsersScreen> createState() => _AdminUsersScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminUsersScreenState extends State<AdminUsersScreen> {
|
||||||
|
late Future<List<AdminUser>> _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<void> _changeRole(AdminUser u, String role) async {
|
||||||
|
try {
|
||||||
|
await apiClient.updateUser(u.id, role: role);
|
||||||
|
_reload();
|
||||||
|
} catch (e) {
|
||||||
|
_snack("$e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resetPassword(AdminUser u) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final saved = await showDialog<bool>(
|
||||||
|
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<void> _delete(AdminUser u) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<void> _createUser() async {
|
||||||
|
final created = await showModalBottomSheet<bool>(
|
||||||
|
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<List<AdminUser>>(
|
||||||
|
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<String>(
|
||||||
|
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<String>(
|
||||||
|
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<void> _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<String>(
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CarFormSheet> createState() => _CarFormSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CarFormSheetState extends State<CarFormSheet> {
|
||||||
|
late final Map<String, TextEditingController> _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<void> _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<DateTime?> 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<String>(
|
||||||
|
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")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DashboardScreen> createState() => _DashboardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashboardScreenState extends State<DashboardScreen> {
|
||||||
|
late Future<List<_CarRow>> _future;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<_CarRow>> _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<void> _refresh() async {
|
||||||
|
final f = _load();
|
||||||
|
setState(() => _future = f);
|
||||||
|
await f;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addCar() async {
|
||||||
|
final created = await showModalBottomSheet<Car?>(
|
||||||
|
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<List<_CarRow>>(
|
||||||
|
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<void> 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<Widget> _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<Color>(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<void> 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")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LockScreen> createState() => _LockScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LockScreenState extends State<LockScreen> {
|
||||||
|
bool _busy = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Prompt once as soon as the lock screen appears.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _unlock());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
final _email = TextEditingController();
|
||||||
|
final _password = TextEditingController();
|
||||||
|
final _server = TextEditingController();
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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<void> _saveServer() async {
|
||||||
|
await apiClient.setServerUrl(_server.text);
|
||||||
|
final current = await apiClient.serverOverride();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_server.text = current;
|
||||||
|
_serverSaved = "Saved ✓";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resetServer() async {
|
||||||
|
await apiClient.setServerUrl("");
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_server.clear();
|
||||||
|
_serverSaved = "Reset ✓";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<bool>(
|
||||||
|
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<void> _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>[];
|
||||||
|
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<void> Function() onSave;
|
||||||
|
final Future<void> 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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RootShell> createState() => _RootShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RootShellState extends State<RootShell> {
|
||||||
|
int _index = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isAdmin = authService.user?.isAdmin == true;
|
||||||
|
|
||||||
|
final pages = <Widget>[
|
||||||
|
const DashboardScreen(),
|
||||||
|
const SettingsScreen(),
|
||||||
|
if (isAdmin) const AdminUsersScreen(),
|
||||||
|
];
|
||||||
|
|
||||||
|
final destinations = <NavigationDestination>[
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||