Add Users and Organizations management to API Server panel

The superadmin panel had backend CRUD for users/orgs (internal/api/users.go,
orgs.go) but no UI. Add two tabs to panel/src/App.vue:

- Users: create form (email/password/role/org), list with role + org badges,
  inline editor (email/role/org/verified/password reset), and delete. Self-role
  change and self-delete are suppressed to mirror the server-side guards.
- Organizations: create, inline rename, and delete, with client-derived member
  counts; delete is disabled while an org still has members.

PocketBase validation errors are unwrapped into a readable line via a shared
apiError() helper. Rebuilds the embedded panel bundle (dist).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-13 18:29:50 +02:00
co-authored by Claude Opus 4.8
parent e9b27530ec
commit 09da889c15
6 changed files with 529 additions and 22 deletions
+509 -2
View File
@@ -9,7 +9,7 @@ import EndpointTable from "./components/EndpointTable.vue";
// and anyone who is not a superadmin is refused.
const TOKEN_KEY = "pv_panel_token";
const token = ref(localStorage.getItem(TOKEN_KEY) || "");
const me = ref(null); // { email, role } once verified as superadmin
const me = ref(null); // { id, email, role } once verified as superadmin
const authed = ref(false);
const booting = ref(true);
@@ -21,6 +21,8 @@ const busy = ref(false);
const tab = ref("overview");
const TABS = [
{ id: "overview", label: "Overview" },
{ id: "users", label: "Users" },
{ id: "organizations", label: "Organizations" },
{ id: "pocketbase", label: "PocketBase" },
{ id: "plugins", label: "Plugins" },
];
@@ -32,7 +34,7 @@ async function verify(tok) {
if (!r.ok) return false;
const who = await r.json();
if (who.role !== "superadmin") return false;
me.value = { email: who.email, role: who.role };
me.value = { id: who.id, email: who.email, role: who.role };
return true;
} catch {
return false;
@@ -46,6 +48,8 @@ async function grant(tok) {
startPolling();
loadPbConfig();
loadPlugins();
loadOrgs();
loadUsers();
}
async function doLogin() {
@@ -362,6 +366,315 @@ async function registerExternal() {
}
}
// ---- Users & Organizations (superadmin) ----------------------------------
// This panel is superadmin-gated, so the UI always operates with the full,
// cross-organization scope the API grants a superadmin.
const users = ref([]); // [{ id, email, role, verified, created, organization, organizationName }]
const orgs = ref([]); // [{ id, name, created }]
const umErr = ref(""); // load error banner (shared by both tabs)
const ROLES = [
{ value: "user", label: "User" },
{ value: "admin", label: "Admin" },
{ value: "superadmin", label: "Superadmin" },
];
const roleLabel = { user: "User", admin: "Admin", superadmin: "Superadmin" };
const roleBadge = {
user: "bg-sunken text-secondary",
admin: "bg-warning-tint text-warning",
superadmin: "bg-success-tint text-success",
};
// Member counts per organization, derived from the (complete, superadmin) user
// list — used to annotate orgs and to explain why a delete is blocked.
const orgMemberCount = computed(() => {
const m = {};
for (const u of users.value) {
if (u.organization) m[u.organization] = (m[u.organization] || 0) + 1;
}
return m;
});
// Turn either our own {error} envelope or a relayed PocketBase validation error
// ({message, data:{field:{message}}}) into a single readable line.
function apiError(d, fallback) {
if (!d) return fallback;
if (d.error) return d.error;
if (d.data && typeof d.data === "object") {
const parts = [];
for (const k in d.data) {
if (d.data[k] && d.data[k].message) parts.push(`${k}: ${d.data[k].message}`);
}
if (parts.length) return parts.join("; ");
}
return d.message || fallback;
}
function orgLabel(id) {
if (!id) return "— none —";
const o = orgs.value.find((x) => x.id === id);
return o ? o.name : id;
}
// -- Users --
const newUser = ref({ email: "", password: "", role: "user", organization: "" });
const newUserErr = ref("");
const newUserMsg = ref("");
const newUserBusy = ref(false);
const editingUser = ref(""); // id of the user whose inline editor is open
const editUser = ref({}); // working copy { email, role, organization, verified, password }
const busyUser = ref(""); // id with an in-flight action
const userMsg = ref("");
async function loadUsers() {
umErr.value = "";
try {
const r = await fetch("/api/users", { headers: authHeaders() });
const d = await r.json().catch(() => ({}));
if (!r.ok) {
umErr.value = apiError(d, "Could not load users.");
return;
}
users.value = d.users || [];
} catch {
umErr.value = "Could not reach the API server.";
}
}
async function createUser() {
newUserErr.value = "";
newUserMsg.value = "";
const email = newUser.value.email.trim().toLowerCase();
if (!email || !email.includes("@")) {
newUserErr.value = "A valid email is required.";
return;
}
if (newUser.value.password.length < 8) {
newUserErr.value = "Password must be at least 8 characters.";
return;
}
newUserBusy.value = true;
try {
const r = await fetch("/api/users", {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({
email,
password: newUser.value.password,
role: newUser.value.role,
organization: newUser.value.organization,
}),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
newUserErr.value = apiError(d, "Could not create the user.");
return;
}
newUser.value = { email: "", password: "", role: "user", organization: "" };
newUserMsg.value = `Created ${d.user ? d.user.email : "user"}.`;
await loadUsers();
} catch {
newUserErr.value = "Could not reach the API server.";
} finally {
newUserBusy.value = false;
}
}
function startUserEdit(u) {
userMsg.value = "";
editingUser.value = editingUser.value === u.id ? "" : u.id;
editUser.value = {
email: u.email,
role: u.role,
organization: u.organization || "",
verified: !!u.verified,
password: "",
};
}
async function saveUser(u) {
userMsg.value = "";
busyUser.value = u.id;
try {
// Only send fields that actually changed; password only when set.
const patch = {};
const e = editUser.value;
if (e.email.trim().toLowerCase() !== u.email) patch.email = e.email.trim().toLowerCase();
if (e.role !== u.role) patch.role = e.role;
if ((e.organization || "") !== (u.organization || "")) patch.organization = e.organization || "";
if (e.verified !== !!u.verified) patch.verified = e.verified;
if (e.password) patch.password = e.password;
if (Object.keys(patch).length === 0) {
userMsg.value = "No changes to save.";
return;
}
const r = await fetch(`/api/users/${encodeURIComponent(u.id)}`, {
method: "PATCH",
headers: authHeaders(true),
body: JSON.stringify(patch),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
userMsg.value = apiError(d, "Could not update the user.");
return;
}
userMsg.value = `Updated ${u.email}.`;
editingUser.value = "";
await loadUsers();
} catch {
userMsg.value = "Could not reach the API server.";
} finally {
busyUser.value = "";
}
}
async function deleteUser(u) {
if (u.id === (me.value && me.value.id)) return; // guarded in the template too
if (!confirm(`Delete user ${u.email}? This cannot be undone.`)) return;
userMsg.value = "";
busyUser.value = u.id;
try {
const r = await fetch(`/api/users/${encodeURIComponent(u.id)}`, {
method: "DELETE",
headers: authHeaders(),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
userMsg.value = apiError(d, "Could not delete the user.");
return;
}
userMsg.value = `Deleted ${u.email}.`;
if (editingUser.value === u.id) editingUser.value = "";
await loadUsers();
} catch {
userMsg.value = "Could not reach the API server.";
} finally {
busyUser.value = "";
}
}
// -- Organizations --
const newOrgName = ref("");
const newOrgErr = ref("");
const newOrgMsg = ref("");
const newOrgBusy = ref(false);
const editingOrg = ref(""); // id of the org being renamed
const editOrgName = ref("");
const busyOrg = ref("");
const orgMsg = ref("");
async function loadOrgs() {
try {
const r = await fetch("/api/orgs", { headers: authHeaders() });
if (!r.ok) return;
const d = await r.json().catch(() => ({}));
orgs.value = d.organizations || [];
} catch {
/* leave orgs as-is; the users tab still works without names */
}
}
async function createOrg() {
newOrgErr.value = "";
newOrgMsg.value = "";
const name = newOrgName.value.trim();
if (!name) {
newOrgErr.value = "An organization name is required.";
return;
}
newOrgBusy.value = true;
try {
const r = await fetch("/api/orgs", {
method: "POST",
headers: authHeaders(true),
body: JSON.stringify({ name }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
newOrgErr.value = apiError(d, "Could not create the organization.");
return;
}
newOrgName.value = "";
newOrgMsg.value = `Created ${d.organization ? d.organization.name : "organization"}.`;
await loadOrgs();
} catch {
newOrgErr.value = "Could not reach the API server.";
} finally {
newOrgBusy.value = false;
}
}
function startOrgEdit(o) {
orgMsg.value = "";
editingOrg.value = editingOrg.value === o.id ? "" : o.id;
editOrgName.value = o.name;
}
async function saveOrg(o) {
orgMsg.value = "";
const name = editOrgName.value.trim();
if (!name) {
orgMsg.value = "An organization name is required.";
return;
}
if (name === o.name) {
editingOrg.value = "";
return;
}
busyOrg.value = o.id;
try {
const r = await fetch(`/api/orgs/${encodeURIComponent(o.id)}`, {
method: "PATCH",
headers: authHeaders(true),
body: JSON.stringify({ name }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
orgMsg.value = apiError(d, "Could not rename the organization.");
return;
}
orgMsg.value = `Renamed to ${name}.`;
editingOrg.value = "";
await loadOrgs();
await loadUsers(); // refresh the resolved org names on user rows
} catch {
orgMsg.value = "Could not reach the API server.";
} finally {
busyOrg.value = "";
}
}
async function deleteOrg(o) {
const members = orgMemberCount.value[o.id] || 0;
if (members > 0) {
orgMsg.value = `${o.name} still has ${members} member${members === 1 ? "" : "s"}; reassign or remove them first.`;
return;
}
if (!confirm(`Delete organization ${o.name}?`)) return;
orgMsg.value = "";
busyOrg.value = o.id;
try {
const r = await fetch(`/api/orgs/${encodeURIComponent(o.id)}`, {
method: "DELETE",
headers: authHeaders(),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
orgMsg.value = apiError(d, "Could not delete the organization.");
return;
}
orgMsg.value = `Deleted ${o.name}.`;
if (editingOrg.value === o.id) editingOrg.value = "";
await loadOrgs();
} catch {
orgMsg.value = "Could not reach the API server.";
} finally {
busyOrg.value = "";
}
}
// Live health poll against this server's aggregate /api/status, which probes
// PocketBase and the Web App server-side (the browser only talks to the API).
const checkedAt = ref(null);
@@ -435,6 +748,8 @@ onMounted(async () => {
startPolling();
loadPbConfig();
loadPlugins();
loadOrgs();
loadUsers();
} else if (token.value) {
localStorage.removeItem(TOKEN_KEY);
token.value = "";
@@ -620,6 +935,198 @@ const deviceApi = [
<EndpointTable title="Device API" auth="Device uplink" :endpoints="deviceApi" />
</div>
<!-- Users tab -->
<div v-show="tab === 'users'" class="flex flex-col gap-6">
<!-- Create user -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Add user</div>
<span class="pv-eyebrow">Create an account</span>
</div>
<div class="flex flex-col gap-3 px-5 py-5">
<div class="flex flex-col gap-3 sm:flex-row">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Email</span>
<input v-model="newUser.email" type="email" class="pv-input" placeholder="pilot@pilotvault.local" autocomplete="off" spellcheck="false" />
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Password</span>
<input v-model="newUser.password" type="password" class="pv-input" placeholder="min. 8 characters" autocomplete="new-password" />
</label>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Role</span>
<select v-model="newUser.role" class="pv-input">
<option v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Organization</span>
<select v-model="newUser.organization" class="pv-input">
<option value=""> none </option>
<option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
</label>
</div>
<p v-if="newUserErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">{{ newUserErr }}</p>
<p v-else-if="newUserMsg" class="rounded-sm bg-success-tint px-3 py-2 text-xs font-medium text-success">{{ newUserMsg }}</p>
<div>
<button class="pv-btn" :disabled="newUserBusy" @click="createUser">{{ newUserBusy ? "Creating" : "Create user" }}</button>
</div>
</div>
</div>
<!-- User list -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-semibold text-primary">Users</div>
<span class="pv-eyebrow">{{ users.length }} total</span>
</div>
<button class="pv-btn-sec pv-btn-sm" @click="loadUsers">Refresh</button>
</div>
<div class="flex flex-col">
<p v-if="umErr" class="px-5 py-3 text-xs font-medium text-danger">{{ umErr }}</p>
<p v-if="userMsg" class="border-b border-subtle bg-sunken px-5 py-2.5 font-mono text-xs text-secondary">{{ userMsg }}</p>
<p v-if="!users.length && !umErr" class="px-5 py-6 text-sm text-secondary">No users yet.</p>
<div v-for="u in users" :key="u.id" class="border-t border-subtle px-5 py-4 first:border-t-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="truncate text-sm font-semibold text-primary">{{ u.email }}</span>
<span class="rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider" :class="roleBadge[u.role]">{{ roleLabel[u.role] || u.role }}</span>
<span v-if="u.id === (me && me.id)" class="rounded-sm bg-brand/10 px-1.5 py-0.5 font-mono text-[10px] font-medium text-brand-text">you</span>
<span v-if="!u.verified" class="rounded-sm bg-warning-tint px-1.5 py-0.5 font-mono text-[10px] font-medium text-warning">unverified</span>
</div>
<div class="mt-0.5 font-mono text-xs text-secondary">
{{ u.organizationName || (u.organization ? u.organization : "no organization") }}
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button class="pv-btn-sec pv-btn-sm" @click="startUserEdit(u)">{{ editingUser === u.id ? "Close" : "Edit" }}</button>
<button
v-if="u.id !== (me && me.id)"
class="pv-btn-sec pv-btn-sm !text-danger"
:disabled="busyUser === u.id"
@click="deleteUser(u)"
>
Delete
</button>
</div>
</div>
<!-- inline editor -->
<div v-if="editingUser === u.id" class="mt-3 flex flex-col gap-3 rounded-md border border-subtle bg-sunken px-4 py-4">
<div class="flex flex-col gap-3 sm:flex-row">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Email</span>
<input v-model="editUser.email" type="email" class="pv-input" autocomplete="off" spellcheck="false" />
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">New password</span>
<input v-model="editUser.password" type="password" class="pv-input" placeholder="leave blank to keep current" autocomplete="new-password" />
</label>
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Role</span>
<select v-model="editUser.role" class="pv-input" :disabled="u.id === (me && me.id)">
<option v-for="r in ROLES" :key="r.value" :value="r.value">{{ r.label }}</option>
</select>
<span v-if="u.id === (me && me.id)" class="text-[11px] text-muted">You cannot change your own role.</span>
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Organization</span>
<select v-model="editUser.organization" class="pv-input">
<option value=""> none </option>
<option v-for="o in orgs" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
</label>
</div>
<label class="flex items-center gap-2">
<input v-model="editUser.verified" type="checkbox" class="h-4 w-4" />
<span class="text-sm text-primary">Verified</span>
</label>
<div class="flex items-center gap-2">
<button class="pv-btn pv-btn-sm" :disabled="busyUser === u.id" @click="saveUser(u)">Save changes</button>
<button class="pv-btn-sec pv-btn-sm" @click="editingUser = ''">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Organizations tab -->
<div v-show="tab === 'organizations'" class="flex flex-col gap-6">
<!-- Create org -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Add organization</div>
<span class="pv-eyebrow">Create a tenant</span>
</div>
<div class="flex flex-col gap-3 px-5 py-5">
<div class="flex flex-col gap-2 sm:flex-row">
<input v-model="newOrgName" class="pv-input flex-1" placeholder="Acme Aerial Ltd." autocomplete="off" spellcheck="false" @keyup.enter="createOrg" />
<button class="pv-btn" :disabled="newOrgBusy" @click="createOrg">{{ newOrgBusy ? "Creating" : "Create" }}</button>
</div>
<p v-if="newOrgErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">{{ newOrgErr }}</p>
<p v-else-if="newOrgMsg" class="rounded-sm bg-success-tint px-3 py-2 text-xs font-medium text-success">{{ newOrgMsg }}</p>
</div>
</div>
<!-- Org list -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div>
<div class="text-base font-semibold text-primary">Organizations</div>
<span class="pv-eyebrow">{{ orgs.length }} total</span>
</div>
<button class="pv-btn-sec pv-btn-sm" @click="loadOrgs">Refresh</button>
</div>
<div class="flex flex-col">
<p v-if="orgMsg" class="border-b border-subtle bg-sunken px-5 py-2.5 font-mono text-xs text-secondary">{{ orgMsg }}</p>
<p v-if="!orgs.length" class="px-5 py-6 text-sm text-secondary">No organizations yet.</p>
<div v-for="o in orgs" :key="o.id" class="border-t border-subtle px-5 py-4 first:border-t-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold text-primary">{{ o.name }}</div>
<div class="mt-0.5 font-mono text-xs text-secondary">
{{ (orgMemberCount[o.id] || 0) }} member{{ (orgMemberCount[o.id] || 0) === 1 ? "" : "s" }}
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<button class="pv-btn-sec pv-btn-sm" @click="startOrgEdit(o)">{{ editingOrg === o.id ? "Close" : "Rename" }}</button>
<button
class="pv-btn-sec pv-btn-sm !text-danger"
:disabled="busyOrg === o.id || (orgMemberCount[o.id] || 0) > 0"
:title="(orgMemberCount[o.id] || 0) > 0 ? 'Reassign or remove members first' : 'Delete organization'"
@click="deleteOrg(o)"
>
Delete
</button>
</div>
</div>
<!-- inline rename -->
<div v-if="editingOrg === o.id" class="mt-3 flex flex-col gap-2 rounded-md border border-subtle bg-sunken px-4 py-4 sm:flex-row sm:items-end">
<label class="flex flex-1 flex-col gap-1">
<span class="pv-eyebrow">Name</span>
<input v-model="editOrgName" class="pv-input" autocomplete="off" spellcheck="false" @keyup.enter="saveOrg(o)" />
</label>
<div class="flex items-center gap-2">
<button class="pv-btn pv-btn-sm" :disabled="busyOrg === o.id" @click="saveOrg(o)">Save</button>
<button class="pv-btn-sec pv-btn-sm" @click="editingOrg = ''">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- PocketBase tab -->
<div v-show="tab === 'pocketbase'" class="flex flex-col gap-6">
<!-- PocketBase connection settings -->