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:
tajniak81andClaude Opus 4.8 committed 2026-07-16 22:29:45 +02:00
1 parent 7d55f0a4cd
commit ae6ed4ac1e
56 files changed
+4474 -1475

No files matched your search

+7 -76
View File
@@ -17,7 +17,6 @@ async function load() {
loadError.value = "";
try {
profile.value = await refreshProfile();
await loadSessions();
} catch (e) {
loadError.value = e.message;
} finally {
@@ -208,49 +207,6 @@ async function saveBio() {
}
}
// --- Privacy & security: sessions ---
const sessions = ref([]);
const sessionsError = ref("");
const revokingId = ref("");
const revokingOthers = ref(false);
async function loadSessions() {
sessions.value = await api.listSessions();
}
async function revokeSession(session) {
if (!confirm(`Log out "${session.deviceLabel}"?`)) return;
revokingId.value = session.id;
sessionsError.value = "";
try {
await api.revokeSession(session.id);
if (session.current) {
onLogout();
return;
}
await loadSessions();
} catch (e) {
sessionsError.value = e.message;
} finally {
revokingId.value = "";
}
}
async function revokeOthers() {
if (!confirm("Log out every other device? This device stays signed in.")) return;
revokingOthers.value = true;
sessionsError.value = "";
try {
await api.revokeOtherSessions();
await loadSessions();
} catch (e) {
sessionsError.value = e.message;
} finally {
revokingOthers.value = false;
}
}
function onLogout() {
logout();
router.replace({ name: "login" });
@@ -565,42 +521,17 @@ onBeforeUnmount(() => {
<section class="dh-card p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold tracking-[-0.02em] text-strong">Privacy &amp; security</h2>
<button
v-if="sessions.length > 1"
class="text-sm font-medium text-brandtext hover:underline disabled:opacity-50"
:disabled="revokingOthers"
@click="revokeOthers"
>
{{ revokingOthers ? "Logging out…" : "Log out all other devices" }}
<button class="text-sm font-medium text-danger hover:underline" @click="onLogout">
Sign out
</button>
</div>
<p class="mb-3 text-sm text-muted">
Two-factor authentication isn't available yet. Active sessions below reflect every device currently signed in.
<p class="text-sm text-muted">
Two-factor authentication isn't available yet. Sessions are held as
server-issued tokens that expire on their own, so signing out here ends
this device's session only — there's no per-device list to revoke from.
To lock out every device, change your password above.
</p>
<p v-if="sessionsError" class="mb-3 text-sm text-danger">{{ sessionsError }}</p>
<ul class="divide-y divide-subtle">
<li v-for="sess in sessions" :key="sess.id" class="flex items-center justify-between gap-3 py-3">
<div>
<p class="text-sm font-medium text-strong">
{{ sess.deviceLabel }}
<span v-if="sess.current" class="dh-badge dh-badge-neutral ml-2">This device</span>
</p>
<p class="text-xs text-muted">
<span class="data">{{ sess.ip }}</span> · signed in {{ formatDate(sess.created) }}
</p>
</div>
<button
class="shrink-0 text-sm font-medium text-danger hover:underline disabled:opacity-50"
:disabled="revokingId === sess.id"
@click="revokeSession(sess)"
>
{{ revokingId === sess.id ? "Logging out…" : "Log out" }}
</button>
</li>
</ul>
</section>
<!-- Advanced / Danger Zone -->