Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.
Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).
Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.
Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.
Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.
PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.
Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.
Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.
Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.
Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
160 lines
5.1 KiB
Go
160 lines
5.1 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"drivervault/apiserver/internal/config"
|
|
"drivervault/apiserver/internal/pb"
|
|
)
|
|
|
|
// pbProbe is the outcome of testing a PocketBase connection: whether the base
|
|
// URL answers its health check and whether the service-account credentials
|
|
// authenticate as a superuser.
|
|
type pbProbe struct {
|
|
Reachable bool `json:"reachable"`
|
|
HTTPStatus int `json:"httpStatus,omitempty"`
|
|
LatencyMs int64 `json:"latencyMs,omitempty"`
|
|
Superuser bool `json:"superuser"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
// pbConfigView is the PocketBase-connection shape returned to the panel. The
|
|
// password itself is never sent back — only whether one is set.
|
|
type pbConfigView struct {
|
|
URL string `json:"url"`
|
|
AdminEmail string `json:"adminEmail"`
|
|
AdminConfigured bool `json:"adminConfigured"`
|
|
Probe pbProbe `json:"probe"`
|
|
}
|
|
|
|
// probePB checks a PocketBase base URL's health and, when credentials are given,
|
|
// whether they authenticate as a superuser. It uses the short-timeout
|
|
// healthClient so a hung PocketBase cannot stall the request.
|
|
func probePB(ctx context.Context, url, email, password string) pbProbe {
|
|
h := probe(ctx, url+"/api/health")
|
|
p := pbProbe{Reachable: h.Status == "ok", HTTPStatus: h.HTTPStatus, LatencyMs: h.LatencyMs}
|
|
if h.Error != "" {
|
|
p.Detail = h.Error
|
|
}
|
|
if email != "" && password != "" {
|
|
st, err := pb.SuperuserAuth(ctx, healthClient, url, email, password)
|
|
if err == nil {
|
|
p.Superuser = true
|
|
} else if p.Reachable {
|
|
p.Detail = "superuser auth failed"
|
|
if st > 0 {
|
|
p.Detail += " (HTTP " + strconv.Itoa(st) + ")"
|
|
}
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// normalizePBURL trims, defaults the scheme to http, and drops a trailing slash.
|
|
func normalizePBURL(u string) string {
|
|
u = strings.TrimSpace(u)
|
|
if u == "" {
|
|
return ""
|
|
}
|
|
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
|
u = "http://" + u
|
|
}
|
|
return strings.TrimRight(u, "/")
|
|
}
|
|
|
|
// viewFor builds the panel's connection view, including a live probe.
|
|
func viewFor(ctx context.Context, url, email, password string) pbConfigView {
|
|
return pbConfigView{
|
|
URL: url,
|
|
AdminEmail: email,
|
|
AdminConfigured: email != "" && password != "",
|
|
Probe: probePB(ctx, url, email, password),
|
|
}
|
|
}
|
|
|
|
// GET /api/admin/pb-config — current PocketBase connection + a live probe.
|
|
func (s *Server) handleGetPBConfig(w http.ResponseWriter, r *http.Request) {
|
|
url, email, password := s.pbSettings()
|
|
writeJSON(w, http.StatusOK, viewFor(r.Context(), url, email, password))
|
|
}
|
|
|
|
// pbConfigBody is the editable connection payload. A blank adminPassword means
|
|
// "keep the current one"; a blank adminEmail/url means "keep current".
|
|
type pbConfigBody struct {
|
|
URL string `json:"url"`
|
|
AdminEmail string `json:"adminEmail"`
|
|
AdminPassword string `json:"adminPassword"`
|
|
}
|
|
|
|
// resolve merges a request body onto the current settings, applying the
|
|
// keep-current semantics for blank fields.
|
|
func (s *Server) resolve(b pbConfigBody) (url, email, password string) {
|
|
curURL, curEmail, curPassword := s.pbSettings()
|
|
url = normalizePBURL(b.URL)
|
|
if url == "" {
|
|
url = curURL
|
|
}
|
|
email = strings.TrimSpace(b.AdminEmail)
|
|
if email == "" {
|
|
email = curEmail
|
|
}
|
|
password = b.AdminPassword
|
|
if password == "" {
|
|
password = curPassword
|
|
}
|
|
return
|
|
}
|
|
|
|
// POST /api/admin/pb-config/test — probe a candidate connection WITHOUT applying
|
|
// it, so a superadmin can verify before saving.
|
|
func (s *Server) handleTestPBConfig(w http.ResponseWriter, r *http.Request) {
|
|
var b pbConfigBody
|
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
url, email, password := s.resolve(b)
|
|
writeJSON(w, http.StatusOK, probePB(r.Context(), url, email, password))
|
|
}
|
|
|
|
// PUT /api/admin/pb-config — apply a new PocketBase connection at runtime and
|
|
// persist it to .env. Returns the new config plus a fresh probe.
|
|
func (s *Server) handleUpdatePBConfig(w http.ResponseWriter, r *http.Request) {
|
|
var b pbConfigBody
|
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
if normalizePBURL(b.URL) == "" {
|
|
writeError(w, http.StatusBadRequest, "a PocketBase URL is required")
|
|
return
|
|
}
|
|
url, email, password := s.resolve(b)
|
|
|
|
// Apply at runtime, then persist so the change survives a restart.
|
|
s.setPBConfig(url, email, password)
|
|
if err := config.UpdateEnvFile(config.EnvFile, map[string]string{
|
|
"POCKETBASE_URL": url,
|
|
"POCKETBASE_ADMIN_EMAIL": email,
|
|
"POCKETBASE_ADMIN_PASSWORD": password,
|
|
}); err != nil {
|
|
// The runtime change already took effect; report that persistence failed.
|
|
log.Printf("pb-config: persist to %s failed: %v", config.EnvFile, err)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"config": viewFor(r.Context(), url, email, password),
|
|
"warning": "applied for this session, but could not be saved to .env: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
log.Printf("pb-config: PocketBase connection updated to %s (by superadmin)", url)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"config": viewFor(r.Context(), url, email, password),
|
|
})
|
|
}
|