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
+26 -16
View File
@@ -45,13 +45,24 @@ async function handleResponse(res, path) {
if (res.status === 204) return null;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const msg = (data && (data.error || data.message)) || res.statusText;
throw new Error(msg);
}
if (!res.ok) throw new Error(errorMessage(data, res.statusText));
return data;
}
// Digs a human-readable message out of the error shapes in play: this server's
// {error}, and PocketBase's {message, data:{field:{message}}} — which the user
// and organization endpoints relay verbatim, so a duplicate email arrives as a
// per-field error rather than a flat string.
function errorMessage(data, fallback) {
if (!data || typeof data !== "object") return fallback;
if (data.error) return data.error;
const fieldErrors = Object.entries(data.data || {})
.map(([field, e]) => `${field}: ${e?.message || e}`)
.filter(Boolean);
if (fieldErrors.length) return fieldErrors.join("; ");
return data.message || fallback;
}
async function request(path, options = {}) {
const res = await fetch(apiBase() + path, {
headers: { "Content-Type": "application/json", ...authHeader(), ...(options.headers || {}) },
@@ -112,13 +123,17 @@ export const api = {
request(`/parts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deletePart: (id) => request(`/parts/${id}`, { method: "DELETE" }),
// Admin — user management (admin role only)
listUsers: () => request("/admin/users"),
createUser: (body) => request("/admin/users", { method: "POST", body: JSON.stringify(body) }),
updateUser: (id, body) => request(`/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
setUserPassword: (id, newPassword) =>
request(`/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ newPassword }) }),
deleteUser: (id) => request(`/admin/users/${id}`, { method: "DELETE" }),
// Admin — user management (admin or superadmin). Admins are scoped by the
// server to their own organization; superadmins see everyone.
listUsers: () => request("/users").then((r) => r.users),
createUser: (body) =>
request("/users", { method: "POST", body: JSON.stringify(body) }).then((r) => r.user),
updateUser: (id, body) =>
request(`/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }).then((r) => r.user),
// Password resets are a field on the user PATCH now, not a separate endpoint.
setUserPassword: (id, password) =>
request(`/users/${id}`, { method: "PATCH", body: JSON.stringify({ password }) }).then((r) => r.user),
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
// Settings — account/profile/appearance
getMe: () => request("/me"),
@@ -134,11 +149,6 @@ export const api = {
deleteAvatar: () => request("/me/avatar", { method: "DELETE" }),
getAvatarBlob: () => requestBlob("/me/avatar"),
// Settings — privacy & security (active sessions)
listSessions: () => request("/sessions"),
revokeSession: (id) => request(`/sessions/${id}`, { method: "DELETE" }),
revokeOtherSessions: () => request("/sessions", { method: "DELETE" }),
// Settings — advanced / danger zone
exportData: () => requestBlob("/me/export"),
importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }),