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
@@ -0,0 +1,126 @@
<script setup>
import { ref, onMounted } from "vue";
import { request } from "../api";
// Superadmin-only: retarget the PocketBase this server talks to. The change is
// applied at runtime AND persisted to the server's .env, so it survives a
// restart. A blank password means "keep the stored one".
const cfg = ref(null);
const form = ref({ url: "", adminEmail: "", adminPassword: "" });
const probe = ref(null);
const error = ref("");
const notice = ref("");
const busy = ref(false);
async function load() {
try {
cfg.value = await request("/api/admin/pb-config");
form.value = {
url: cfg.value.url,
adminEmail: cfg.value.adminEmail,
adminPassword: "",
};
probe.value = cfg.value.probe;
} catch (e) {
error.value = e.message;
}
}
onMounted(load);
async function test() {
error.value = "";
notice.value = "";
busy.value = true;
try {
probe.value = await request("/api/admin/pb-config/test", {
method: "POST",
body: form.value,
});
notice.value = probe.value.superuser
? "Connection OK — superuser authenticated."
: probe.value.reachable
? "PocketBase is reachable, but the service account did not authenticate."
: "PocketBase is not reachable at that address.";
} catch (e) {
error.value = e.message;
} finally {
busy.value = false;
}
}
async function save() {
error.value = "";
notice.value = "";
busy.value = true;
try {
const out = await request("/api/admin/pb-config", { method: "PUT", body: form.value });
cfg.value = out.config;
probe.value = out.config.probe;
form.value.adminPassword = "";
notice.value = out.warning || "Saved. The server is now using this PocketBase.";
} catch (e) {
error.value = e.message;
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-bold tracking-[-0.02em] text-strong">PocketBase</div>
<p class="mt-0.5 text-xs text-muted">Database connection used by every endpoint</p>
</div>
<span
v-if="probe"
class="dh-pill"
:class="probe.superuser
? 'bg-success-soft text-success'
: probe.reachable
? 'bg-warning-soft text-warning'
: 'bg-danger-soft text-danger'"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ probe.superuser ? "connected" : probe.reachable ? "no superuser" : "unreachable" }}
</span>
</div>
<div class="flex flex-col gap-4 px-5 py-4">
<div>
<label class="dh-label" for="pb-url">Base URL</label>
<input id="pb-url" v-model="form.url" class="dh-input" placeholder="http://10.2.1.10:8027" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="dh-label" for="pb-email">Superuser email</label>
<input id="pb-email" v-model="form.adminEmail" class="dh-input" autocomplete="off" />
</div>
<div>
<label class="dh-label" for="pb-password">Superuser password</label>
<input
id="pb-password"
v-model="form.adminPassword"
class="dh-input"
type="password"
autocomplete="new-password"
:placeholder="cfg?.adminConfigured ? 'unchanged' : 'not set'"
/>
</div>
</div>
<p v-if="probe?.detail" class="data text-xs text-muted">{{ probe.detail }}</p>
<p v-if="notice" class="rounded-control bg-info-soft px-3 py-2 text-xs text-info">{{ notice }}</p>
<p v-if="error" class="rounded-control bg-danger-soft px-3 py-2 text-xs text-danger">{{ error }}</p>
<div class="flex items-center gap-2">
<button class="dh-btn" :disabled="busy" @click="save">Save &amp; apply</button>
<button class="dh-btn-ghost" :disabled="busy" @click="test">Test connection</button>
<span class="flex-1"></span>
<span class="eyebrow">persisted to .env</span>
</div>
</div>
</div>
</template>