Rebuild API Server on the PilotVault structure

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>
This commit is contained in:
tajniak81
2026-07-16 22:29:45 +02:00
co-authored by Claude Opus 4.8
parent 7d55f0a4cd
commit ae6ed4ac1e
56 changed files with 4474 additions and 1475 deletions
+43 -44
View File
@@ -9,8 +9,7 @@ import (
"strings"
"time"
"carcontrol/api/internal/auth"
"carcontrol/api/internal/models"
"drivervault/apiserver/internal/models"
)
// deletionCooldown is how long an account-deletion request sits before it can
@@ -65,19 +64,19 @@ func orDefault(v, fallback string) string {
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 {
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 {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
rec, err := s.fetchUser(r, claims.Sub)
rec, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
@@ -102,8 +101,8 @@ var validFontSizes = map[string]bool{"small": true, "medium": true, "large": tru
// 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 {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -146,7 +145,7 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
var rec userRecord
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, &rec); err != nil {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil {
writePBError(w, err)
return
}
@@ -159,8 +158,8 @@ type changePasswordRequest struct {
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -180,13 +179,13 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
// 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 {
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 {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
writePBError(w, err)
return
}
@@ -194,8 +193,8 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -216,11 +215,11 @@ func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection, claims.Sub, nil, "avatar", header.Filename, data); err != nil {
if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection(), claims.ID, nil, "avatar", header.Filename, data); err != nil {
writePBError(w, err)
return
}
rec, err := s.fetchUser(r, claims.Sub)
rec, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
@@ -229,12 +228,12 @@ func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
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 {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, map[string]any{"avatar": ""}, nil); err != nil {
writePBError(w, err)
return
}
@@ -242,12 +241,12 @@ func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
rec, err := s.fetchUser(r, claims.Sub)
rec, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
@@ -256,7 +255,7 @@ func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "no avatar set")
return
}
data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection, rec.ID, rec.Avatar)
data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection(), rec.ID, rec.Avatar)
if err != nil {
writePBError(w, err)
return
@@ -267,12 +266,12 @@ func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if err := s.pb.RequestVerification(r.Context(), s.usersCollection, claims.Email); err != nil {
if err := s.pb.RequestVerification(r.Context(), s.usersCollection(), claims.Email); err != nil {
writePBError(w, err)
return
}
@@ -283,19 +282,19 @@ func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Reques
// (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 {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
user, err := s.fetchUser(r, claims.Sub)
user, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
}
carsRes, err := s.pb.List(r.Context(), colCars, url.Values{
"filter": {fmt.Sprintf("owner='%s'", claims.Sub)},
"filter": {fmt.Sprintf("owner='%s'", claims.ID)},
"sort": {"name"},
"perPage": {"200"},
})
@@ -396,8 +395,8 @@ type importResult struct {
// 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 {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -423,7 +422,7 @@ func (s *Server) handleImportData(w http.ResponseWriter, r *http.Request) {
// Imported cars are owned by the importing user, regardless of any
// owner in the file.
payload := carPayload(car)
payload["owner"] = claims.Sub
payload["owner"] = claims.ID
var rec carRecord
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
@@ -460,8 +459,8 @@ type deleteAccountRequest struct {
// 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 {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -477,7 +476,7 @@ func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
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 {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
writePBError(w, err)
return
}
@@ -488,13 +487,13 @@ func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
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 {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
writePBError(w, err)
return
}
@@ -507,12 +506,12 @@ func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) {
// 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 {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
rec, err := s.fetchUser(r, claims.Sub)
rec, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
@@ -526,7 +525,7 @@ func (s *Server) handleFinalizeDeletion(w http.ResponseWriter, r *http.Request)
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 {
if err := s.pb.Delete(r.Context(), s.usersCollection(), claims.ID); err != nil {
writePBError(w, err)
return
}