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>
346 lines
10 KiB
Go
346 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// userView is the trimmed user shape returned to managers.
|
|
type userView 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"`
|
|
Organization string `json:"organization"` // org record id ("" = none)
|
|
OrganizationName string `json:"organizationName"` // resolved name ("" = none)
|
|
}
|
|
|
|
// userFields is the field set fetched for a userView.
|
|
const userFields = "id,email,name,role,verified,created,organization"
|
|
|
|
// getUserRecord fetches a single user via the service account. Returns nil (not
|
|
// an error) when the user does not exist.
|
|
func (s *Server) getUserRecord(ctx context.Context, id string) (*userView, error) {
|
|
path := "/api/collections/" + s.usersCollection() + "/records/" + url.PathEscape(id) + "?fields=" + userFields
|
|
data, status, err := s.pb.Raw(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, nil
|
|
}
|
|
var v userView
|
|
if err := json.Unmarshal(data, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
if v.Role == "" {
|
|
v.Role = roleUser
|
|
}
|
|
return &v, nil
|
|
}
|
|
|
|
// GET /api/users — list users (manager only). Superadmins see everyone; admins
|
|
// see only their own organization's members.
|
|
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
path := "/api/collections/" + s.usersCollection() + "/records?perPage=500&sort=email&fields=" + userFields
|
|
if !who.isSuperadmin() {
|
|
// Admin: scope to their own organization. An org-less admin manages nobody.
|
|
if who.OrgID == "" {
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": []userView{}})
|
|
return
|
|
}
|
|
path += "&filter=" + url.QueryEscape("organization = \""+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 []userView `json:"items"`
|
|
}
|
|
_ = json.Unmarshal(data, &list)
|
|
names := s.orgNameMap(r.Context())
|
|
for i := range list.Items {
|
|
if list.Items[i].Role == "" {
|
|
list.Items[i].Role = roleUser
|
|
}
|
|
list.Items[i].OrganizationName = names[list.Items[i].Organization]
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"users": list.Items})
|
|
}
|
|
|
|
// POST /api/users — create a user (manager only). Body: {email, password, name?,
|
|
// role?, organization?}. Admins may only create within their own org and may not
|
|
// mint superadmins; superadmins may target any org (or none) and any role.
|
|
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
Organization string `json:"organization"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
body.Email = strings.TrimSpace(strings.ToLower(body.Email))
|
|
if body.Email == "" || !strings.Contains(body.Email, "@") {
|
|
writeError(w, http.StatusBadRequest, "a valid email is required")
|
|
return
|
|
}
|
|
if len(body.Password) < 8 {
|
|
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
return
|
|
}
|
|
|
|
role, ok := normalizeRole(body.Role)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
|
|
return
|
|
}
|
|
org := strings.TrimSpace(body.Organization)
|
|
|
|
if !who.isSuperadmin() {
|
|
// Admin: no superadmins, and members are forced into the admin's own org.
|
|
if role == roleSuperadmin {
|
|
writeError(w, http.StatusForbidden, "only a superadmin can create superadmins")
|
|
return
|
|
}
|
|
if who.OrgID == "" {
|
|
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
|
return
|
|
}
|
|
org = who.OrgID
|
|
}
|
|
|
|
create := map[string]any{
|
|
"email": body.Email,
|
|
"password": body.Password,
|
|
"passwordConfirm": body.Password,
|
|
"name": strings.TrimSpace(body.Name),
|
|
"role": role,
|
|
"verified": true,
|
|
"emailVisibility": false,
|
|
}
|
|
// Only send organization when set; a superadmin may deliberately omit it to
|
|
// create an org-less account.
|
|
if org != "" {
|
|
create["organization"] = org
|
|
}
|
|
|
|
data, status, err := s.pb.Raw(r.Context(), http.MethodPost, "/api/collections/"+s.usersCollection()+"/records", create)
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
// Relay PocketBase's validation error (e.g. duplicate email, bad org id).
|
|
relay(w, status, data)
|
|
return
|
|
}
|
|
var rec userView
|
|
_ = json.Unmarshal(data, &rec)
|
|
if rec.Role == "" {
|
|
rec.Role = role
|
|
}
|
|
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
|
|
writeJSON(w, http.StatusCreated, map[string]any{"user": rec})
|
|
}
|
|
|
|
// PATCH /api/users/{id} — edit a user (manager only). Any subset of
|
|
// {email, name, role, password, verified, organization} may be supplied. Admins
|
|
// are scoped to their own org and cannot touch superadmins or grant the
|
|
// superadmin role; nobody can change their own role (avoids self-lockout).
|
|
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "missing user id")
|
|
return
|
|
}
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
Name *string `json:"name"`
|
|
Role string `json:"role"`
|
|
Password string `json:"password"`
|
|
Verified *bool `json:"verified"`
|
|
Organization *string `json:"organization"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
// Resolve the target so we can enforce org/role scoping.
|
|
target, err := s.getUserRecord(r.Context(), id)
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if target == nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
|
|
if !who.isSuperadmin() {
|
|
// Admin scoping: target must be inside the admin's org and not a superadmin.
|
|
if who.OrgID == "" || target.Organization != who.OrgID {
|
|
writeError(w, http.StatusForbidden, "user is outside your organization")
|
|
return
|
|
}
|
|
if target.Role == roleSuperadmin {
|
|
writeError(w, http.StatusForbidden, "you cannot edit a superadmin")
|
|
return
|
|
}
|
|
}
|
|
|
|
patch := map[string]any{}
|
|
|
|
if email := strings.TrimSpace(strings.ToLower(body.Email)); email != "" {
|
|
if !strings.Contains(email, "@") {
|
|
writeError(w, http.StatusBadRequest, "a valid email is required")
|
|
return
|
|
}
|
|
patch["email"] = email
|
|
}
|
|
if body.Name != nil {
|
|
patch["name"] = strings.TrimSpace(*body.Name)
|
|
}
|
|
if body.Role != "" {
|
|
role, ok := normalizeRole(body.Role)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
|
|
return
|
|
}
|
|
if !who.isSuperadmin() && role == roleSuperadmin {
|
|
writeError(w, http.StatusForbidden, "only a superadmin can grant the superadmin role")
|
|
return
|
|
}
|
|
if who.ID == id && role != who.Role {
|
|
writeError(w, http.StatusBadRequest, "you cannot change your own role")
|
|
return
|
|
}
|
|
patch["role"] = role
|
|
}
|
|
if body.Password != "" {
|
|
if len(body.Password) < 8 {
|
|
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
return
|
|
}
|
|
patch["password"] = body.Password
|
|
patch["passwordConfirm"] = body.Password
|
|
}
|
|
if body.Verified != nil {
|
|
patch["verified"] = *body.Verified
|
|
}
|
|
// Organization moves are superadmin-only; admins cannot reassign membership.
|
|
if body.Organization != nil {
|
|
if !who.isSuperadmin() {
|
|
if *body.Organization != who.OrgID {
|
|
writeError(w, http.StatusForbidden, "you cannot move users to another organization")
|
|
return
|
|
}
|
|
// no-op for admins staying in their own org
|
|
} else {
|
|
patch["organization"] = *body.Organization // "" clears membership
|
|
}
|
|
}
|
|
if len(patch) == 0 {
|
|
writeError(w, http.StatusBadRequest, "no changes provided")
|
|
return
|
|
}
|
|
|
|
data, status, err := s.pb.Raw(r.Context(), http.MethodPatch,
|
|
"/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(id), patch)
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if status != http.StatusOK {
|
|
relay(w, status, data)
|
|
return
|
|
}
|
|
var rec userView
|
|
_ = json.Unmarshal(data, &rec)
|
|
if rec.Role == "" {
|
|
rec.Role = roleUser
|
|
}
|
|
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
|
|
writeJSON(w, http.StatusOK, map[string]any{"user": rec})
|
|
}
|
|
|
|
// DELETE /api/users/{id} — delete a user (manager only). Admins may delete only
|
|
// non-superadmin members of their own org; nobody can delete their own account.
|
|
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|
who := caller(r)
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
writeError(w, http.StatusBadRequest, "missing user id")
|
|
return
|
|
}
|
|
if who.ID == id {
|
|
writeError(w, http.StatusBadRequest, "you cannot delete your own account")
|
|
return
|
|
}
|
|
|
|
if !who.isSuperadmin() {
|
|
target, err := s.getUserRecord(r.Context(), id)
|
|
if err != nil {
|
|
writeUpstreamDown(w, err)
|
|
return
|
|
}
|
|
if target == nil {
|
|
writeError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
if who.OrgID == "" || target.Organization != who.OrgID {
|
|
writeError(w, http.StatusForbidden, "user is outside your organization")
|
|
return
|
|
}
|
|
if target.Role == roleSuperadmin {
|
|
writeError(w, http.StatusForbidden, "you cannot delete a superadmin")
|
|
return
|
|
}
|
|
}
|
|
|
|
data, status, err := s.pb.Raw(r.Context(), http.MethodDelete,
|
|
"/api/collections/"+s.usersCollection()+"/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})
|
|
}
|
|
|
|
// normalizeRole validates a client-supplied role. Returns the canonical value
|
|
// and whether it was recognised.
|
|
func normalizeRole(role string) (string, bool) {
|
|
switch strings.TrimSpace(strings.ToLower(role)) {
|
|
case "", roleUser:
|
|
return roleUser, true
|
|
case roleAdmin:
|
|
return roleAdmin, true
|
|
case roleSuperadmin:
|
|
return roleSuperadmin, true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|