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:
co-authored by
Claude Opus 4.8
parent
e9b27530ec
commit
09da889c15
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0F1E3D" />
|
<meta name="theme-color" content="#0F1E3D" />
|
||||||
<title>PilotVault · API Server</title>
|
<title>PilotVault · API Server</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DKDpmK_V.js"></script>
|
<script type="module" crossorigin src="/assets/index-DJSuRngH.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BwP7TTth.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CVh8EDzq.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import EndpointTable from "./components/EndpointTable.vue";
|
|||||||
// and anyone who is not a superadmin is refused.
|
// and anyone who is not a superadmin is refused.
|
||||||
const TOKEN_KEY = "pv_panel_token";
|
const TOKEN_KEY = "pv_panel_token";
|
||||||
const token = ref(localStorage.getItem(TOKEN_KEY) || "");
|
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 authed = ref(false);
|
||||||
const booting = ref(true);
|
const booting = ref(true);
|
||||||
|
|
||||||
@@ -21,6 +21,8 @@ const busy = ref(false);
|
|||||||
const tab = ref("overview");
|
const tab = ref("overview");
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: "overview", label: "Overview" },
|
{ id: "overview", label: "Overview" },
|
||||||
|
{ id: "users", label: "Users" },
|
||||||
|
{ id: "organizations", label: "Organizations" },
|
||||||
{ id: "pocketbase", label: "PocketBase" },
|
{ id: "pocketbase", label: "PocketBase" },
|
||||||
{ id: "plugins", label: "Plugins" },
|
{ id: "plugins", label: "Plugins" },
|
||||||
];
|
];
|
||||||
@@ -32,7 +34,7 @@ async function verify(tok) {
|
|||||||
if (!r.ok) return false;
|
if (!r.ok) return false;
|
||||||
const who = await r.json();
|
const who = await r.json();
|
||||||
if (who.role !== "superadmin") return false;
|
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;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -46,6 +48,8 @@ async function grant(tok) {
|
|||||||
startPolling();
|
startPolling();
|
||||||
loadPbConfig();
|
loadPbConfig();
|
||||||
loadPlugins();
|
loadPlugins();
|
||||||
|
loadOrgs();
|
||||||
|
loadUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doLogin() {
|
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
|
// 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).
|
// PocketBase and the Web App server-side (the browser only talks to the API).
|
||||||
const checkedAt = ref(null);
|
const checkedAt = ref(null);
|
||||||
@@ -435,6 +748,8 @@ onMounted(async () => {
|
|||||||
startPolling();
|
startPolling();
|
||||||
loadPbConfig();
|
loadPbConfig();
|
||||||
loadPlugins();
|
loadPlugins();
|
||||||
|
loadOrgs();
|
||||||
|
loadUsers();
|
||||||
} else if (token.value) {
|
} else if (token.value) {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
token.value = "";
|
token.value = "";
|
||||||
@@ -620,6 +935,198 @@ const deviceApi = [
|
|||||||
<EndpointTable title="Device API" auth="Device uplink" :endpoints="deviceApi" />
|
<EndpointTable title="Device API" auth="Device uplink" :endpoints="deviceApi" />
|
||||||
</div>
|
</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 -->
|
<!-- PocketBase tab -->
|
||||||
<div v-show="tab === 'pocketbase'" class="flex flex-col gap-6">
|
<div v-show="tab === 'pocketbase'" class="flex flex-col gap-6">
|
||||||
<!-- PocketBase connection settings -->
|
<!-- PocketBase connection settings -->
|
||||||
|
|||||||
Reference in New Issue
Block a user