Initial commit

This commit is contained in:
tajniak81
2026-07-06 08:50:52 +02:00
commit ba3f227361
153 changed files with 18116 additions and 0 deletions
+32
View File
@@ -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
}
]
}
+35
View File
@@ -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)"
]
}
}
+17
View File
@@ -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
+16
View File
@@ -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/
+22
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
.env
*.exe
/tmp/
/bin/
+39
View File
@@ -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"]
+190
View File
@@ -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.
+20
View File
@@ -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}"
+3
View File
@@ -0,0 +1,3 @@
module carcontrol/api
go 1.22
+260
View File
@@ -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
}
+272
View File
@@ -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})
}
+235
View File
@@ -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
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10
View File
@@ -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

+15
View File
@@ -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>
+534
View File
@@ -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)
}
+23
View File
@@ -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)
}
+124
View File
@@ -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)
}
+192
View File
@@ -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,
}
}
+225
View File
@@ -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)
}
+188
View File
@@ -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
}
+106
View File
@@ -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)})
}
+202
View File
@@ -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
}
+96
View File
@@ -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)
}
+95
View File
@@ -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)
}
}
}
+140
View File
@@ -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
}
}
+362
View File
@@ -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
}
+79
View File
@@ -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
}
+14
View File
@@ -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>
+1964
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"
}
}
+10
View File
@@ -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

+196
View File
@@ -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 &amp; 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>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import "./theme";
createApp(App).mount("#app");
+219
View File
@@ -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);
}
+35
View File
@@ -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);
+20
View File
@@ -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);
});
+76
View File
@@ -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);
});
+156
View File
@@ -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()
+63
View File
@@ -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);
});
+311
View File
@@ -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);
});
+20
View File
@@ -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=
+161
View File
@@ -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"]
+35
View File
@@ -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:
+22
View File
@@ -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=
+73
View File
@@ -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:
+34
View File
@@ -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"]
+12
View File
@@ -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
+9
View File
@@ -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\\
+45
View File
@@ -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
+33
View File
@@ -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'
+102
View File
@@ -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 Keystorebacked 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
```
+5
View File
@@ -0,0 +1,5 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
+14
View File
@@ -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
+45
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

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>
+24
View File
@@ -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)
}
+10
View File
@@ -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
+26
View File
@@ -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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+254
View File
@@ -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");
}
+70
View File
@@ -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,
};
}
+74
View File
@@ -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();
}
}
+114
View File
@@ -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();
+14
View File
@@ -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",
);
+85
View File
@@ -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;
}
+109
View File
@@ -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();
},
),
),
);
}
}
+300
View File
@@ -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"),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+249
View File
@@ -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")),
),
),
],
),
),
);
}
}
+355
View File
@@ -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")),
],
),
),
),
],
);
}
}
+110
View File
@@ -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"),
),
],
),
),
),
);
}
}
+383
View File
@@ -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)),
),
],
),
],
],
);
}
}
+61
View File
@@ -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,
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More