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>
194 lines
5.5 KiB
Go
194 lines
5.5 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// orgView is the trimmed organization shape returned to clients.
|
|
type orgView struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Created string `json:"created"`
|
|
}
|
|
|
|
// orgNameMap returns an id→name map of all organizations via the service
|
|
// account. On any error it returns an empty (non-nil) map so callers can index
|
|
// it safely.
|
|
func (s *Server) orgNameMap(ctx context.Context) map[string]string {
|
|
out := map[string]string{}
|
|
if !s.pb.Configured() {
|
|
return out
|
|
}
|
|
data, status, err := s.pb.Raw(ctx, http.MethodGet,
|
|
"/api/collections/"+colOrgs+"/records?perPage=500&fields=id,name", nil)
|
|
if err != nil || status != http.StatusOK {
|
|
return out
|
|
}
|
|
var list struct {
|
|
Items []orgView `json:"items"`
|
|
}
|
|
_ = json.Unmarshal(data, &list)
|
|
for _, o := range list.Items {
|
|
out[o.ID] = o.Name
|
|
}
|
|
return out
|
|
}
|
|
|
|
// orgName resolves a single organization's name (best effort; "" on miss).
|
|
func (s *Server) orgName(ctx context.Context, id string) string {
|
|
if id == "" || !s.pb.Configured() {
|
|
return ""
|
|
}
|
|
data, status, err := s.pb.Raw(ctx, http.MethodGet,
|
|
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(id)+"?fields=id,name", nil)
|
|
if err != nil || status != http.StatusOK {
|
|
return ""
|
|
}
|
|
var o orgView
|
|
_ = json.Unmarshal(data, &o)
|
|
return o.Name
|
|
}
|
|
|
|
// GET /api/orgs — list organizations (manager only). Superadmins see all;
|
|
// admins see only their own organization.
|
|
func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
path := "/api/collections/" + colOrgs + "/records?perPage=500&sort=name&fields=id,name,created"
|
|
if !who.isSuperadmin() {
|
|
if who.OrgID == "" {
|
|
writeJSON(w, http.StatusOK, map[string]any{"organizations": []orgView{}})
|
|
return
|
|
}
|
|
path += "&filter=" + url.QueryEscape("id = \""+who.OrgID+"\"")
|
|
}
|
|
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, path, nil)
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
relay(w, status, data)
|
|
return
|
|
}
|
|
var list struct {
|
|
Items []orgView `json:"items"`
|
|
}
|
|
_ = json.Unmarshal(data, &list)
|
|
writeJSON(w, http.StatusOK, map[string]any{"organizations": list.Items})
|
|
}
|
|
|
|
// POST /api/orgs — create an organization (superadmin only). Body: {name}.
|
|
func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
|
name, ok := decodeOrgName(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
data, status, err := s.pb.Raw(r.Context(), http.MethodPost,
|
|
"/api/collections/"+colOrgs+"/records", map[string]any{"name": name})
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
// Relay PocketBase's error (e.g. duplicate name violates the unique index).
|
|
relay(w, status, data)
|
|
return
|
|
}
|
|
var org orgView
|
|
_ = json.Unmarshal(data, &org)
|
|
writeJSON(w, http.StatusCreated, map[string]any{"organization": org})
|
|
}
|
|
|
|
// PATCH /api/orgs/{id} — rename an organization (superadmin only). Body: {name}.
|
|
func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "missing organization id")
|
|
return
|
|
}
|
|
name, ok := decodeOrgName(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
data, status, err := s.pb.Raw(r.Context(), http.MethodPatch,
|
|
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(id), map[string]any{"name": name})
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
relay(w, status, data)
|
|
return
|
|
}
|
|
var org orgView
|
|
_ = json.Unmarshal(data, &org)
|
|
writeJSON(w, http.StatusOK, map[string]any{"organization": org})
|
|
}
|
|
|
|
// DELETE /api/orgs/{id} — delete an organization (superadmin only). Refused
|
|
// while the org still has members, to avoid silently orphaning users.
|
|
func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "missing organization id")
|
|
return
|
|
}
|
|
|
|
// Guard: block deletion if any user still belongs to this org.
|
|
countPath := "/api/collections/" + s.usersCollection() + "/records?perPage=1&fields=id&filter=" +
|
|
url.QueryEscape("organization = \""+id+"\"")
|
|
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, countPath, nil)
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if status == http.StatusOK {
|
|
var page struct {
|
|
TotalItems int `json:"totalItems"`
|
|
}
|
|
_ = json.Unmarshal(data, &page)
|
|
if page.TotalItems > 0 {
|
|
writeError(w, http.StatusConflict, "organization still has members; reassign or remove them first")
|
|
return
|
|
}
|
|
}
|
|
|
|
data, status, err = s.pb.Raw(r.Context(), http.MethodDelete,
|
|
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(id), nil)
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if status != http.StatusOK && status != http.StatusNoContent {
|
|
relay(w, status, data)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// decodeOrgName parses and validates a {name} body, writing an error response
|
|
// and returning ok=false on failure.
|
|
func decodeOrgName(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return "", false
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
if name == "" {
|
|
writeError(w, http.StatusBadRequest, "organization name is required")
|
|
return "", false
|
|
}
|
|
if len(name) > 120 {
|
|
writeError(w, http.StatusBadRequest, "organization name is too long (max 120)")
|
|
return "", false
|
|
}
|
|
return name, true
|
|
}
|