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:
co-authored by
Claude Opus 4.8
parent
7d55f0a4cd
commit
ae6ed4ac1e
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { isSuperadmin, request } from "../api";
|
||||
|
||||
// Listing is manager-scoped (an admin sees only their own org); creating,
|
||||
// renaming and deleting are superadmin-only, matching the server's gates.
|
||||
const orgs = ref([]);
|
||||
const error = ref("");
|
||||
const busy = ref(false);
|
||||
const editing = ref(null); // org id, or "new"
|
||||
const draftName = ref("");
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const out = await request("/api/orgs");
|
||||
orgs.value = out.organizations || [];
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
function startNew() {
|
||||
editing.value = "new";
|
||||
draftName.value = "";
|
||||
}
|
||||
|
||||
function startEdit(o) {
|
||||
editing.value = o.id;
|
||||
draftName.value = o.name;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing.value = null;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
async function save() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
if (editing.value === "new") {
|
||||
await request("/api/orgs", { method: "POST", body: { name: draftName.value } });
|
||||
} else {
|
||||
await request(`/api/orgs/${editing.value}`, {
|
||||
method: "PATCH",
|
||||
body: { name: draftName.value },
|
||||
});
|
||||
}
|
||||
editing.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(o) {
|
||||
if (!confirm(`Delete the organization "${o.name}"?`)) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await request(`/api/orgs/${o.id}`, { method: "DELETE" });
|
||||
await load();
|
||||
} catch (e) {
|
||||
// The server refuses (409) while the org still has members.
|
||||
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">Organizations</div>
|
||||
<p class="mt-0.5 text-xs text-muted">Tenants users belong to</p>
|
||||
</div>
|
||||
<button v-if="isSuperadmin" class="dh-btn" @click="startNew">New organization</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="border-b border-subtle px-5 py-3 text-xs text-danger">{{ error }}</p>
|
||||
|
||||
<div v-if="editing" class="border-b border-subtle bg-sunken px-5 py-4">
|
||||
<label class="dh-label">Name</label>
|
||||
<input v-model="draftName" class="dh-input" placeholder="Acme Fleet" @keyup.enter="save" />
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button class="dh-btn" :disabled="busy || !draftName.trim()" @click="save">
|
||||
{{ editing === "new" ? "Create" : "Save" }}
|
||||
</button>
|
||||
<button class="dh-btn-ghost" :disabled="busy" @click="cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!orgs.length" class="px-5 py-6 text-center text-sm text-muted">
|
||||
No organizations yet.
|
||||
</p>
|
||||
|
||||
<table v-else class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
|
||||
<th>Name</th>
|
||||
<th>ID</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="o in orgs" :key="o.id" class="border-t border-subtle transition-colors hover:bg-sunken">
|
||||
<td class="px-5 py-2.5 font-medium text-strong">{{ o.name }}</td>
|
||||
<td class="data px-5 py-2.5 text-xs text-muted">{{ o.id }}</td>
|
||||
<td class="px-5 py-2.5 text-right whitespace-nowrap">
|
||||
<template v-if="isSuperadmin">
|
||||
<button class="dh-btn-ghost" @click="startEdit(o)">Rename</button>
|
||||
<button class="dh-btn-danger ml-1.5" :disabled="busy" @click="remove(o)">
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user