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
+26
-16
@@ -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) }),
|
||||
|
||||
+11
-3
@@ -15,19 +15,27 @@ export const state = reactive({
|
||||
|
||||
export const isAuthenticated = computed(() => !!state.token);
|
||||
|
||||
// Roles that may manage users. A superadmin is an admin that also spans every
|
||||
// organization; the API Server enforces the difference, the UI just needs to
|
||||
// know whether to offer the Users screen at all.
|
||||
const MANAGER_ROLES = ["admin", "superadmin"];
|
||||
|
||||
// Admin gate for the UI. Driven by the full profile (fetched from /api/me),
|
||||
// which always reflects the current role from the DB — so a promotion/demotion
|
||||
// takes effect on the next profile refresh without needing a re-login.
|
||||
export const isAdmin = computed(
|
||||
() => state.profile?.role === "admin" || state.user?.role === "admin"
|
||||
() => MANAGER_ROLES.includes(state.profile?.role) || MANAGER_ROLES.includes(state.user?.role)
|
||||
);
|
||||
|
||||
export async function login(email, password) {
|
||||
// The API Server proxies login to PocketBase and relays its response
|
||||
// verbatim, so the user is under `record` (PocketBase's name) and the token
|
||||
// is PocketBase's own — this app no longer holds a server-minted JWT.
|
||||
const res = await api.login(email, password);
|
||||
state.token = res.token;
|
||||
state.user = res.user;
|
||||
state.user = res.record;
|
||||
localStorage.setItem(TOKEN_KEY, res.token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(res.user));
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(res.record));
|
||||
await refreshProfile();
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { api } from "../api";
|
||||
import { state } from "../auth";
|
||||
import { formatDate } from "../lib/format.js";
|
||||
@@ -22,17 +22,26 @@ const savingPw = ref(false);
|
||||
const pwError = ref("");
|
||||
|
||||
const myId = state.user?.id;
|
||||
const adminCount = () => users.value.filter((u) => u.role === "admin").length;
|
||||
const myRole = computed(() => state.profile?.role || state.user?.role || "user");
|
||||
const isSuperadmin = computed(() => myRole.value === "superadmin");
|
||||
|
||||
// Whether the destructive/demote controls should be disabled for a row, with a
|
||||
// reason (mirrors the server guards so the UI doesn't offer a doomed action).
|
||||
// Roles this viewer may hand out. Only a superadmin can mint another one; the
|
||||
// server rejects it either way, this just doesn't offer a doomed option.
|
||||
const assignableRoles = computed(() =>
|
||||
isSuperadmin.value ? ["user", "admin", "superadmin"] : ["user", "admin"],
|
||||
);
|
||||
|
||||
// Whether the destructive/role controls should be disabled for a row, with a
|
||||
// reason. These mirror the server's guards so the UI doesn't offer an action
|
||||
// that is going to come back as a 400/403.
|
||||
function deleteBlockedReason(u) {
|
||||
if (u.id === myId) return "You can't delete your own account.";
|
||||
if (u.role === "admin" && adminCount() <= 1) return "Can't delete the last admin.";
|
||||
if (u.role === "superadmin" && !isSuperadmin.value) return "Only a superadmin can delete a superadmin.";
|
||||
return "";
|
||||
}
|
||||
function roleLockReason(u) {
|
||||
if (u.role === "admin" && adminCount() <= 1) return "Can't demote the last admin.";
|
||||
if (u.id === myId) return "You can't change your own role.";
|
||||
if (u.role === "superadmin" && !isSuperadmin.value) return "Only a superadmin can edit a superadmin.";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -119,7 +128,10 @@ onMounted(load);
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">Users</h1>
|
||||
<p class="mt-1 text-sm text-muted">Manage accounts and roles.</p>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{{ isSuperadmin ? "Accounts across every organization." : "Accounts in your organization." }}
|
||||
Organizations are assigned in the API panel.
|
||||
</p>
|
||||
</div>
|
||||
<button class="dh-btn dh-btn-primary" @click="showCreate = true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" class="h-4 w-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14M5 12h14" /></svg>
|
||||
@@ -136,6 +148,7 @@ onMounted(load);
|
||||
<tr class="[&>th]:eyebrow [&>th]:px-4 [&>th]:py-3">
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Organization</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
@@ -148,6 +161,7 @@ onMounted(load);
|
||||
<span v-if="u.id === myId" class="ml-1 text-xs text-muted">(you)</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-body">{{ u.name || '—' }}</td>
|
||||
<td class="px-4 py-3 text-body">{{ u.organizationName || '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<select
|
||||
:value="u.role"
|
||||
@@ -156,8 +170,9 @@ onMounted(load);
|
||||
class="dh-input w-auto !py-1 !text-xs disabled:opacity-60"
|
||||
@change="changeRole(u, $event.target.value)"
|
||||
>
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
<option v-for="r in assignableRoles" :key="r" :value="r">{{ r }}</option>
|
||||
<!-- Keep the current role selectable even when this viewer can't assign it. -->
|
||||
<option v-if="!assignableRoles.includes(u.role)" :value="u.role">{{ u.role }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-4 py-3 data text-muted">{{ formatDate(u.created) }}</td>
|
||||
@@ -197,8 +212,7 @@ onMounted(load);
|
||||
<div>
|
||||
<label class="dh-label">Role</label>
|
||||
<select v-model="createForm.role" class="dh-input">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
<option v-for="r in assignableRoles" :key="r" :value="r">{{ r }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 & 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 -->
|
||||
|
||||
Reference in New Issue
Block a user