Reorder the Status card meta() detail so HTTP status and URL come first and the latency (ms) reads last, e.g. "HTTP 200 · <url> · 12ms". Rebuild the embedded panel assets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1315 lines
53 KiB
Vue
1315 lines
53 KiB
Vue
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
|
import { theme, toggleTheme } from "./theme";
|
|
import EndpointTable from "./components/EndpointTable.vue";
|
|
|
|
// ---- Auth gate: this panel is restricted to superadmins ------------------
|
|
// The token is kept only for this browser (localStorage). Login proxies through
|
|
// the API Server to PocketBase; the caller's role is then confirmed via /api/me,
|
|
// 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); // { id, email, role } once verified as superadmin
|
|
const authed = ref(false);
|
|
const booting = ref(true);
|
|
|
|
const form = ref({ email: "", password: "" });
|
|
const loginErr = ref("");
|
|
const busy = ref(false);
|
|
|
|
// Console tabs.
|
|
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" },
|
|
];
|
|
|
|
// Verify a token resolves to a superadmin. Returns true when access is granted.
|
|
async function verify(tok) {
|
|
try {
|
|
const r = await fetch("/api/me", { headers: { Authorization: tok } });
|
|
if (!r.ok) return false;
|
|
const who = await r.json();
|
|
if (who.role !== "superadmin") return false;
|
|
me.value = { id: who.id, email: who.email, role: who.role };
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function grant(tok) {
|
|
token.value = tok;
|
|
localStorage.setItem(TOKEN_KEY, tok);
|
|
authed.value = true;
|
|
startPolling();
|
|
loadPbConfig();
|
|
loadPlugins();
|
|
loadOrgs();
|
|
loadUsers();
|
|
}
|
|
|
|
async function doLogin() {
|
|
loginErr.value = "";
|
|
const email = form.value.email.trim().toLowerCase();
|
|
if (!email || !form.value.password) {
|
|
loginErr.value = "Enter your email and password.";
|
|
return;
|
|
}
|
|
busy.value = true;
|
|
try {
|
|
const r = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password: form.value.password }),
|
|
});
|
|
const body = await r.json().catch(() => ({}));
|
|
if (!r.ok || !body.token) {
|
|
loginErr.value = "Invalid email or password.";
|
|
return;
|
|
}
|
|
// Authenticated — now enforce the superadmin-only rule.
|
|
if (!(await verify(body.token))) {
|
|
loginErr.value = "Access to this panel is restricted to superadmins.";
|
|
return;
|
|
}
|
|
form.value.password = "";
|
|
await grant(body.token);
|
|
} catch {
|
|
loginErr.value = "Cannot reach the API server.";
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
function logout() {
|
|
stopPolling();
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
token.value = "";
|
|
me.value = null;
|
|
authed.value = false;
|
|
}
|
|
|
|
// ---- PocketBase connection settings (superadmin) -------------------------
|
|
const pb = ref(null); // { url, adminEmail, adminConfigured, probe }
|
|
const pbForm = ref({ url: "", adminEmail: "", adminPassword: "" });
|
|
const pbProbe = ref(null); // most recent test/save probe result
|
|
const pbMsg = ref("");
|
|
const pbErr = ref("");
|
|
const pbBusy = ref(false);
|
|
const pbTesting = ref(false);
|
|
|
|
function authHeaders(json) {
|
|
const h = { Authorization: token.value };
|
|
if (json) h["Content-Type"] = "application/json";
|
|
return h;
|
|
}
|
|
|
|
async function loadPbConfig() {
|
|
try {
|
|
const r = await fetch("/api/admin/pb-config", { headers: authHeaders() });
|
|
if (!r.ok) return;
|
|
const d = await r.json();
|
|
pb.value = d;
|
|
pbForm.value = { url: d.url || "", adminEmail: d.adminEmail || "", adminPassword: "" };
|
|
pbProbe.value = d.probe || null;
|
|
} catch {
|
|
/* leave settings unloaded; the card shows a retry */
|
|
}
|
|
}
|
|
|
|
async function testPbConfig() {
|
|
pbMsg.value = "";
|
|
pbErr.value = "";
|
|
pbTesting.value = true;
|
|
try {
|
|
const r = await fetch("/api/admin/pb-config/test", {
|
|
method: "POST",
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify(pbForm.value),
|
|
});
|
|
pbProbe.value = await r.json();
|
|
} catch {
|
|
pbErr.value = "Could not run the test.";
|
|
} finally {
|
|
pbTesting.value = false;
|
|
}
|
|
}
|
|
|
|
async function savePbConfig() {
|
|
pbMsg.value = "";
|
|
pbErr.value = "";
|
|
if (!pbForm.value.url.trim()) {
|
|
pbErr.value = "A PocketBase URL is required.";
|
|
return;
|
|
}
|
|
pbBusy.value = true;
|
|
try {
|
|
const r = await fetch("/api/admin/pb-config", {
|
|
method: "PUT",
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify(pbForm.value),
|
|
});
|
|
const d = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
pbErr.value = d.error || "Could not save the connection.";
|
|
return;
|
|
}
|
|
pb.value = d.config;
|
|
pbProbe.value = d.config?.probe || null;
|
|
pbForm.value = { url: d.config.url, adminEmail: d.config.adminEmail, adminPassword: "" };
|
|
pbMsg.value = d.warning || "Connection saved.";
|
|
} catch {
|
|
pbErr.value = "Could not reach the API server.";
|
|
} finally {
|
|
pbBusy.value = false;
|
|
}
|
|
}
|
|
|
|
// ---- Plugins (external-service integrations, superadmin) ------------------
|
|
const plugins = ref([]); // [{ name, provider, version, kind, capabilities, authType, configFields, enabled, config, baseURL, health }]
|
|
const pluginsErr = ref("");
|
|
const pluginsMsg = ref("");
|
|
const editingPlugin = ref(""); // name whose config form is expanded
|
|
const editConfig = ref({}); // working copy of the expanded plugin's config
|
|
const busyPlugin = ref(""); // name of a plugin with an in-flight action
|
|
const newExt = ref({ name: "", baseURL: "", provider: "" });
|
|
const newExtErr = ref("");
|
|
const newExtBusy = ref(false);
|
|
|
|
// Plugins are grouped into category tabs (mirrors the Web App's Integrations tabs).
|
|
// A plugin's server-side `category` picks its tab; an unset category falls back to APIs — External.
|
|
const PLUGIN_CATEGORIES = [
|
|
{ id: "apis-external", label: "APIs — External" },
|
|
{ id: "drives-external", label: "Drives — External" },
|
|
{ id: "drives-local", label: "Drives — Local" },
|
|
];
|
|
const pluginTab = ref("apis-external");
|
|
function pluginCategory(p) {
|
|
return p.category || "apis-external";
|
|
}
|
|
function pluginsInCategory(id) {
|
|
return plugins.value.filter((p) => pluginCategory(p) === id);
|
|
}
|
|
const activePlugins = computed(() => pluginsInCategory(pluginTab.value));
|
|
|
|
const kindBadge = {
|
|
builtin: "bg-success-tint text-success",
|
|
external: "bg-warning-tint text-warning",
|
|
};
|
|
const healthBadge = {
|
|
ok: "bg-success-tint text-success",
|
|
degraded: "bg-warning-tint text-warning",
|
|
down: "bg-danger-tint text-danger",
|
|
};
|
|
|
|
async function loadPlugins() {
|
|
pluginsErr.value = "";
|
|
try {
|
|
const r = await fetch("/api/admin/plugins", { headers: authHeaders() });
|
|
if (!r.ok) {
|
|
pluginsErr.value = "Could not load plugins.";
|
|
return;
|
|
}
|
|
const d = await r.json();
|
|
plugins.value = d.plugins || [];
|
|
// Populate health (and live credit usage) for enabled plugins that don't have
|
|
// it yet — e.g. right after a server restart. Cached server-side afterwards,
|
|
// so this probes at most once per plugin per server run.
|
|
for (const p of plugins.value) {
|
|
if (p.enabled && !p.health) autoCheckHealth(p.name);
|
|
}
|
|
} catch {
|
|
pluginsErr.value = "Could not reach the API server.";
|
|
}
|
|
}
|
|
|
|
async function autoCheckHealth(name) {
|
|
try {
|
|
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(name)}/health`, {
|
|
method: "POST",
|
|
headers: authHeaders(),
|
|
});
|
|
const d = await r.json().catch(() => ({}));
|
|
if (r.ok && d.health) {
|
|
const t = pluginBy(name);
|
|
if (t) t.health = d.health;
|
|
}
|
|
} catch {
|
|
/* leave health unset */
|
|
}
|
|
}
|
|
|
|
function pluginBy(name) {
|
|
return plugins.value.find((p) => p.name === name);
|
|
}
|
|
|
|
async function togglePlugin(p) {
|
|
busyPlugin.value = p.name;
|
|
pluginsMsg.value = "";
|
|
try {
|
|
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
|
|
method: "PUT",
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify({ enabled: !p.enabled }),
|
|
});
|
|
const d = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
pluginsMsg.value = d.error || "Could not update the plugin.";
|
|
} else if (d.warning) {
|
|
pluginsMsg.value = `${p.name}: ${d.warning}`;
|
|
}
|
|
await loadPlugins();
|
|
} finally {
|
|
busyPlugin.value = "";
|
|
}
|
|
}
|
|
|
|
function startPluginEdit(p) {
|
|
editingPlugin.value = editingPlugin.value === p.name ? "" : p.name;
|
|
const cfg = { ...(p.config || {}) };
|
|
// Preselect each field's effective default when nothing is stored yet.
|
|
for (const f of p.configFields || []) {
|
|
if ((cfg[f.key] === undefined || cfg[f.key] === "") && f.default) cfg[f.key] = f.default;
|
|
}
|
|
editConfig.value = cfg;
|
|
}
|
|
|
|
async function savePluginConfig(p) {
|
|
busyPlugin.value = p.name;
|
|
pluginsMsg.value = "";
|
|
try {
|
|
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
|
|
method: "PUT",
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify({ config: editConfig.value }),
|
|
});
|
|
const d = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
pluginsMsg.value = d.error || "Could not save the configuration.";
|
|
} else {
|
|
pluginsMsg.value = d.warning ? `${p.name}: ${d.warning}` : "Configuration saved.";
|
|
editingPlugin.value = "";
|
|
}
|
|
await loadPlugins();
|
|
} finally {
|
|
busyPlugin.value = "";
|
|
}
|
|
}
|
|
|
|
async function checkPlugin(p) {
|
|
busyPlugin.value = p.name;
|
|
pluginsMsg.value = "";
|
|
try {
|
|
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
|
|
method: "POST",
|
|
headers: authHeaders(),
|
|
});
|
|
const d = await r.json().catch(() => ({}));
|
|
if (r.ok && d.health) {
|
|
const t = pluginBy(p.name);
|
|
if (t) t.health = d.health;
|
|
} else {
|
|
pluginsMsg.value = d.error || "Health check failed.";
|
|
}
|
|
} finally {
|
|
busyPlugin.value = "";
|
|
}
|
|
}
|
|
|
|
async function removePlugin(p) {
|
|
if (p.kind !== "external") return;
|
|
busyPlugin.value = p.name;
|
|
try {
|
|
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}`, {
|
|
method: "DELETE",
|
|
headers: authHeaders(),
|
|
});
|
|
if (!r.ok) {
|
|
const d = await r.json().catch(() => ({}));
|
|
pluginsMsg.value = d.error || "Could not remove the plugin.";
|
|
}
|
|
await loadPlugins();
|
|
} finally {
|
|
busyPlugin.value = "";
|
|
}
|
|
}
|
|
|
|
async function registerExternal() {
|
|
newExtErr.value = "";
|
|
if (!newExt.value.name.trim() || !newExt.value.baseURL.trim()) {
|
|
newExtErr.value = "Name and base URL are required.";
|
|
return;
|
|
}
|
|
newExtBusy.value = true;
|
|
try {
|
|
const r = await fetch("/api/admin/plugins", {
|
|
method: "POST",
|
|
headers: authHeaders(true),
|
|
body: JSON.stringify(newExt.value),
|
|
});
|
|
const d = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
newExtErr.value = d.error || "Could not register the plugin.";
|
|
return;
|
|
}
|
|
newExt.value = { name: "", baseURL: "", provider: "" };
|
|
pluginsMsg.value = "External plugin registered.";
|
|
await loadPlugins();
|
|
} catch {
|
|
newExtErr.value = "Could not reach the API server.";
|
|
} finally {
|
|
newExtBusy.value = false;
|
|
}
|
|
}
|
|
|
|
// ---- 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);
|
|
const devices = ref(null);
|
|
const svc = ref({
|
|
apiServer: { status: "checking", detail: "" },
|
|
pocketBase: { status: "checking", detail: "" },
|
|
webApp: { status: "checking", detail: "" },
|
|
});
|
|
let timer = null;
|
|
|
|
const rows = [
|
|
{ key: "apiServer", label: "API server" },
|
|
{ key: "pocketBase", label: "PocketBase" },
|
|
{ key: "webApp", label: "Web App" },
|
|
];
|
|
|
|
const badge = {
|
|
checking: { label: "checking", cls: "bg-sunken text-secondary" },
|
|
ok: { label: "operational", cls: "bg-success-tint text-success" },
|
|
down: { label: "unreachable", cls: "bg-danger-tint text-danger" },
|
|
unreachable: { label: "unreachable", cls: "bg-danger-tint text-danger" },
|
|
};
|
|
|
|
function meta(h) {
|
|
const bits = [];
|
|
if (h.httpStatus) bits.push("HTTP " + h.httpStatus);
|
|
if (h.url) bits.push(h.url);
|
|
if (typeof h.latencyMs === "number") bits.push(h.latencyMs + "ms");
|
|
return bits.join(" · ");
|
|
}
|
|
|
|
async function check() {
|
|
try {
|
|
const r = await fetch("/api/status");
|
|
if (!r.ok) throw new Error("status " + r.status);
|
|
const b = await r.json();
|
|
const api = b.apiServer || {};
|
|
devices.value = api.devices ?? 0;
|
|
svc.value = {
|
|
apiServer: { status: "ok", detail: "" },
|
|
pocketBase: { status: b.pocketBase?.status === "ok" ? "ok" : "down", detail: meta(b.pocketBase || {}) },
|
|
webApp: { status: b.webApp?.status === "ok" ? "ok" : "down", detail: meta(b.webApp || {}) },
|
|
};
|
|
} catch {
|
|
// The API server itself is unreachable → status of everything is unknown.
|
|
devices.value = null;
|
|
svc.value = {
|
|
apiServer: { status: "unreachable", detail: "" },
|
|
pocketBase: { status: "unreachable", detail: "" },
|
|
webApp: { status: "unreachable", detail: "" },
|
|
};
|
|
}
|
|
checkedAt.value = new Date();
|
|
}
|
|
|
|
function startPolling() {
|
|
check();
|
|
clearInterval(timer);
|
|
timer = setInterval(check, 10000);
|
|
}
|
|
function stopPolling() {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// Resume a previous superadmin session if the stored token still checks out.
|
|
if (token.value && (await verify(token.value))) {
|
|
authed.value = true;
|
|
startPolling();
|
|
loadPbConfig();
|
|
loadPlugins();
|
|
loadOrgs();
|
|
loadUsers();
|
|
} else if (token.value) {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
token.value = "";
|
|
}
|
|
booting.value = false;
|
|
});
|
|
onUnmounted(() => stopPolling());
|
|
|
|
// Client / dashboard API — used by the Web App and the API Web Panel.
|
|
const clientApi = [
|
|
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a session (via PocketBase)" },
|
|
{ method: "GET", path: "/api/auth/validate", desc: "Validate the current session token" },
|
|
{ method: "GET", path: "/api/me", desc: "Caller's id, email, role, and organization from their token" },
|
|
{ method: "GET", path: "/api/preferences", desc: "Read the caller's saved settings blob" },
|
|
{ method: "PUT", path: "/api/preferences", desc: "Persist the caller's settings onto their user record" },
|
|
{ method: "GET", path: "/api/devices", desc: "List connected devices and last-known state" },
|
|
{ method: "GET", path: "/api/devices/{id}/track", desc: "GPS track history for a device" },
|
|
{ method: "POST", path: "/api/devices/{id}/command", desc: "Send a command down to a device" },
|
|
{ method: "DELETE", path: "/api/devices/{id}", desc: "Forget a device's stored state" },
|
|
{ method: "GET", path: "/ws/ui", desc: "Live telemetry stream (WebSocket)" },
|
|
];
|
|
|
|
// Management API — user + organization management. Requires a manager
|
|
// (admin or superadmin) token; admins are scoped to their own organization,
|
|
// superadmins span all of them.
|
|
const managementApi = [
|
|
{ method: "GET", path: "/api/users", desc: "List users (admin: own org · superadmin: all)" },
|
|
{ method: "POST", path: "/api/users", desc: "Create a user {email, password, role, organization?}" },
|
|
{ method: "PATCH", path: "/api/users/{id}", desc: "Edit a user (role/org changes are scope-checked)" },
|
|
{ method: "DELETE", path: "/api/users/{id}", desc: "Delete a user (not self; admins in-org only)" },
|
|
{ method: "GET", path: "/api/orgs", desc: "List organizations (admin: own · superadmin: all)" },
|
|
{ method: "POST", path: "/api/orgs", desc: "Create an organization {name} (superadmin)" },
|
|
{ method: "PATCH", path: "/api/orgs/{id}", desc: "Rename an organization (superadmin)" },
|
|
{ method: "DELETE", path: "/api/orgs/{id}", desc: "Delete an empty organization (superadmin)" },
|
|
{ method: "GET", path: "/api/admin/pb-config", desc: "Read the PocketBase connection + live probe (superadmin)" },
|
|
{ method: "POST", path: "/api/admin/pb-config/test", desc: "Test a candidate connection without applying (superadmin)" },
|
|
{ method: "PUT", path: "/api/admin/pb-config", desc: "Update + persist the PocketBase connection (superadmin)" },
|
|
{ method: "GET", path: "/api/admin/plugins", desc: "List plugins + state + last health (superadmin)" },
|
|
{ method: "POST", path: "/api/admin/plugins", desc: "Register an external plugin {name, baseURL} (superadmin)" },
|
|
{ method: "PUT", path: "/api/admin/plugins/{name}", desc: "Enable/disable + configure a plugin (superadmin)" },
|
|
{ method: "DELETE", path: "/api/admin/plugins/{name}", desc: "Remove an external plugin (superadmin)" },
|
|
{ method: "POST", path: "/api/admin/plugins/{name}/health", desc: "Run a plugin health check (superadmin)" },
|
|
];
|
|
|
|
// Device API — used by the Fly App running on the drone/controller.
|
|
const deviceApi = [
|
|
{ method: "GET", path: "/ws/device?id={id}", desc: "Device telemetry uplink (WebSocket)" },
|
|
{ method: "POST", path: "/api/telemetry?id={id}", desc: "Push a single telemetry event over HTTP" },
|
|
{ method: "GET", path: "/healthz", desc: "Readiness probe" },
|
|
];
|
|
</script>
|
|
|
|
<template>
|
|
<div class="mx-auto flex max-w-5xl flex-col gap-6 px-6 pt-12 pb-16">
|
|
<!-- Header -->
|
|
<div class="flex items-center gap-3">
|
|
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" aria-hidden="true">
|
|
<g stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="8,30 19,17 30,30" stroke="var(--brand)" />
|
|
<polyline points="18,33 29,20 40,33" stroke="currentColor" />
|
|
</g>
|
|
</svg>
|
|
<div class="leading-tight text-primary">
|
|
<div class="font-display text-lg font-bold tracking-tight">PilotVault</div>
|
|
<span class="pv-eyebrow">API server</span>
|
|
</div>
|
|
<div class="flex-1"></div>
|
|
<span v-if="authed && me" class="pv-eyebrow hidden truncate sm:inline">{{ me.email }}</span>
|
|
<button v-if="authed" class="pv-btn-sec pv-btn-sm" @click="logout">Sign out</button>
|
|
<button
|
|
class="pv-btn-sec pv-btn-sm"
|
|
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
|
|
@click="toggleTheme"
|
|
>
|
|
<!-- sun (shown in dark mode) -->
|
|
<svg v-if="theme === 'dark'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="4" />
|
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
|
</svg>
|
|
<!-- moon (shown in light mode) -->
|
|
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
</svg>
|
|
Theme
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Booting: resolving a stored session -->
|
|
<div v-if="booting" class="rounded-lg border border-subtle bg-card px-5 py-10 text-center shadow-sm">
|
|
<span class="pv-eyebrow">Checking session…</span>
|
|
</div>
|
|
|
|
<!-- Login gate — superadmin only -->
|
|
<div v-else-if="!authed" class="mx-auto w-full max-w-sm 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">Sign in</div>
|
|
<span class="pv-eyebrow">Superadmin access only</span>
|
|
</div>
|
|
<form class="flex flex-col gap-3 px-5 py-5" @submit.prevent="doLogin">
|
|
<label class="flex flex-col gap-1">
|
|
<span class="pv-eyebrow">Email</span>
|
|
<input
|
|
v-model="form.email"
|
|
type="email"
|
|
autocomplete="username"
|
|
class="pv-input"
|
|
placeholder="superadmin@pilotvault.local"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1">
|
|
<span class="pv-eyebrow">Password</span>
|
|
<input
|
|
v-model="form.password"
|
|
type="password"
|
|
autocomplete="current-password"
|
|
class="pv-input"
|
|
placeholder="••••••••"
|
|
/>
|
|
</label>
|
|
<p v-if="loginErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">
|
|
{{ loginErr }}
|
|
</p>
|
|
<button type="submit" class="pv-btn mt-1" :disabled="busy">
|
|
{{ busy ? "Signing in…" : "Sign in" }}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Console — visible only to an authenticated superadmin -->
|
|
<template v-else>
|
|
<!-- Tabs -->
|
|
<div class="flex gap-1 self-start rounded-lg border border-subtle bg-card p-1 shadow-sm">
|
|
<button
|
|
v-for="t in TABS"
|
|
:key="t.id"
|
|
class="rounded-md px-3.5 py-1.5 text-sm font-semibold transition"
|
|
:class="tab === t.id ? 'bg-brand text-on-brand' : 'text-secondary hover:text-primary'"
|
|
@click="tab = t.id"
|
|
>
|
|
{{ t.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Overview tab -->
|
|
<div v-show="tab === 'overview'" class="flex flex-col gap-6">
|
|
<!-- Status -->
|
|
<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 class="text-base font-semibold text-primary">Status</div>
|
|
<span class="pv-eyebrow">{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
|
|
</div>
|
|
<div>
|
|
<div
|
|
v-for="row in rows"
|
|
:key="row.key"
|
|
class="flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5 first:border-t-0"
|
|
>
|
|
<div class="min-w-0">
|
|
<div class="text-sm font-semibold text-primary">{{ row.label }}</div>
|
|
<div v-if="svc[row.key].detail" class="truncate font-mono text-xs text-secondary">{{ svc[row.key].detail }}</div>
|
|
</div>
|
|
<span
|
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
|
|
:class="badge[svc[row.key].status].cls"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
|
{{ badge[svc[row.key].status].label }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Devices metric -->
|
|
<div class="flex items-center justify-between gap-3 border-t border-subtle px-5 py-3.5">
|
|
<div class="text-sm font-semibold text-primary">Devices</div>
|
|
<span class="font-mono text-xs text-secondary">
|
|
{{ devices === null ? "—" : devices + " online" }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<EndpointTable title="Client API" auth="PocketBase session" :endpoints="clientApi" />
|
|
<EndpointTable title="Management API" auth="Admin · superadmin" :endpoints="managementApi" />
|
|
<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 -->
|
|
<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">PocketBase connection</div>
|
|
<span class="pv-eyebrow">Settings</span>
|
|
</div>
|
|
<span
|
|
v-if="pbProbe"
|
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
|
|
:class="pbProbe.reachable ? (pbProbe.superuser ? 'bg-success-tint text-success' : 'bg-warning-tint text-warning') : 'bg-danger-tint text-danger'"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
|
|
{{ pbProbe.reachable ? (pbProbe.superuser ? "connected" : "reachable") : "unreachable" }}
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-col gap-3 px-5 py-5">
|
|
<label class="flex flex-col gap-1">
|
|
<span class="pv-eyebrow">PocketBase URL</span>
|
|
<input v-model="pbForm.url" class="pv-input" placeholder="http://10.2.1.10:8026" spellcheck="false" />
|
|
</label>
|
|
<label class="flex flex-col gap-1">
|
|
<span class="pv-eyebrow">Service account email</span>
|
|
<input v-model="pbForm.adminEmail" class="pv-input" placeholder="admin@pilotvault.local" autocomplete="off" spellcheck="false" />
|
|
</label>
|
|
<label class="flex flex-col gap-1">
|
|
<span class="pv-eyebrow">Service account password</span>
|
|
<input
|
|
v-model="pbForm.adminPassword"
|
|
type="password"
|
|
class="pv-input"
|
|
autocomplete="new-password"
|
|
:placeholder="pb && pb.adminConfigured ? 'leave blank to keep current' : 'set a password'"
|
|
/>
|
|
</label>
|
|
|
|
<div v-if="pbProbe" class="font-mono text-[11px] text-muted">
|
|
health: {{ pbProbe.reachable ? "ok" : "down" }}<span v-if="pbProbe.latencyMs"> · {{ pbProbe.latencyMs }}ms</span>
|
|
· superuser auth: {{ pbProbe.superuser ? "ok" : "—" }}<span v-if="pbProbe.detail"> · {{ pbProbe.detail }}</span>
|
|
</div>
|
|
|
|
<p v-if="pbErr" class="rounded-sm bg-danger-tint px-3 py-2 text-xs font-medium text-danger">{{ pbErr }}</p>
|
|
<p v-else-if="pbMsg" class="rounded-sm bg-success-tint px-3 py-2 text-xs font-medium text-success">{{ pbMsg }}</p>
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button class="pv-btn" :disabled="pbBusy" @click="savePbConfig">{{ pbBusy ? "Saving…" : "Save connection" }}</button>
|
|
<button class="pv-btn-sec" :disabled="pbTesting" @click="testPbConfig">{{ pbTesting ? "Testing…" : "Test connection" }}</button>
|
|
</div>
|
|
<p class="text-[11px] text-muted">
|
|
The service account is used only for user & organization management. Changing the URL
|
|
repoints the whole API Server at a new PocketBase and may sign you out.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Plugins tab -->
|
|
<div v-show="tab === 'plugins'" class="flex flex-col gap-6">
|
|
<!-- Plugins -->
|
|
<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">Plugins</div>
|
|
<span class="pv-eyebrow">External integrations</span>
|
|
</div>
|
|
<button class="pv-btn-sec pv-btn-sm" @click="loadPlugins">Refresh</button>
|
|
</div>
|
|
|
|
<div class="flex flex-col">
|
|
<p v-if="pluginsErr" class="px-5 py-3 text-xs font-medium text-danger">{{ pluginsErr }}</p>
|
|
<p v-if="pluginsMsg" class="border-b border-subtle bg-sunken px-5 py-2.5 font-mono text-xs text-secondary">{{ pluginsMsg }}</p>
|
|
<p v-if="!plugins.length && !pluginsErr" class="px-5 py-6 text-sm text-secondary">No plugins registered yet.</p>
|
|
|
|
<!-- category tabs -->
|
|
<div v-if="plugins.length" class="flex gap-1 overflow-x-auto overflow-y-hidden border-b border-subtle px-3 pt-1">
|
|
<button
|
|
v-for="t in PLUGIN_CATEGORIES"
|
|
:key="t.id"
|
|
class="-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition"
|
|
:class="pluginTab === t.id ? 'border-brand text-primary' : 'border-transparent text-secondary hover:text-primary'"
|
|
@click="pluginTab = t.id"
|
|
>
|
|
{{ t.label }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- per-tab empty state -->
|
|
<p v-if="plugins.length && !activePlugins.length" class="px-5 py-6 text-sm text-secondary">No plugins in this category.</p>
|
|
|
|
<!-- plugin rows -->
|
|
<div v-for="p in activePlugins" :key="p.name" 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="text-sm font-semibold text-primary">{{ p.name }}</span>
|
|
<span class="rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium uppercase tracking-wider" :class="kindBadge[p.kind]">{{ p.kind }}</span>
|
|
<span
|
|
v-if="p.health"
|
|
class="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 font-mono text-[10px] font-medium"
|
|
:class="healthBadge[p.health.status]"
|
|
:title="p.health.detail || ''"
|
|
>
|
|
<span class="h-1 w-1 rounded-full bg-current"></span>{{ p.health.status }}<span v-if="p.health.latencyMs"> · {{ p.health.latencyMs }}ms</span>
|
|
</span>
|
|
</div>
|
|
<div class="mt-0.5 font-mono text-xs text-secondary">{{ p.provider }} · v{{ p.version }} · {{ p.authType }}</div>
|
|
<div v-if="p.health && p.health.detail" class="mt-0.5 text-[11px] text-secondary">{{ p.health.detail }}</div>
|
|
<div v-if="p.capabilities && p.capabilities.length" class="mt-2 flex flex-col gap-1.5">
|
|
<div class="pv-eyebrow">Capabilities</div>
|
|
<div v-for="c in p.capabilities" :key="c.id" class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
<span class="rounded-sm bg-sunken px-1.5 py-0.5 font-mono text-[10px] text-secondary">{{ c.id }}</span>
|
|
<span v-if="c.method || c.endpoint" class="font-mono text-[10px] text-muted">{{ c.method }} {{ c.endpoint }}</span>
|
|
<span v-if="c.description" class="w-full text-[11px] text-secondary sm:w-auto">{{ c.description }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-if="p.baseURL" class="mt-1 truncate font-mono text-[11px] text-muted">{{ p.baseURL }}</div>
|
|
</div>
|
|
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<button
|
|
class="pv-btn-sec pv-btn-sm"
|
|
:disabled="busyPlugin === p.name"
|
|
:class="p.enabled ? '!border-transparent !bg-success-tint !text-success' : ''"
|
|
@click="togglePlugin(p)"
|
|
>
|
|
<span class="h-1.5 w-1.5 rounded-full" :class="p.enabled ? 'bg-success' : 'bg-muted'"></span>
|
|
{{ p.enabled ? "Enabled" : "Disabled" }}
|
|
</button>
|
|
<button v-if="p.configFields && p.configFields.length" class="pv-btn-sec pv-btn-sm" @click="startPluginEdit(p)">Configure</button>
|
|
<button class="pv-btn-sec pv-btn-sm" :disabled="busyPlugin === p.name" @click="checkPlugin(p)">Check</button>
|
|
<button v-if="p.kind === 'external'" class="pv-btn-sec pv-btn-sm !text-danger" :disabled="busyPlugin === p.name" @click="removePlugin(p)">Remove</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- config form -->
|
|
<div v-if="editingPlugin === p.name" class="mt-3 flex flex-col gap-2 rounded-md border border-subtle bg-sunken px-4 py-4">
|
|
<label v-for="f in p.configFields" :key="f.key" class="flex flex-col gap-1">
|
|
<span class="pv-eyebrow">{{ f.label }}<span v-if="f.required" class="text-danger"> *</span></span>
|
|
<select v-if="f.type === 'select'" v-model="editConfig[f.key]" class="pv-input">
|
|
<option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
</select>
|
|
<input
|
|
v-else
|
|
v-model="editConfig[f.key]"
|
|
:type="f.secret || f.type === 'password' ? 'password' : f.type === 'number' ? 'number' : 'text'"
|
|
class="pv-input"
|
|
autocomplete="off"
|
|
:placeholder="f.help || ''"
|
|
/>
|
|
<span v-if="f.help" class="text-[11px] text-muted">{{ f.help }}</span>
|
|
</label>
|
|
<div class="flex items-center gap-2">
|
|
<button class="pv-btn pv-btn-sm" :disabled="busyPlugin === p.name" @click="savePluginConfig(p)">Save configuration</button>
|
|
<button class="pv-btn-sec pv-btn-sm" @click="editingPlugin = ''">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- register external plugin -->
|
|
<div class="border-t border-subtle px-5 py-4">
|
|
<div class="pv-eyebrow mb-2">Register external plugin</div>
|
|
<div class="flex flex-col gap-2 sm:flex-row">
|
|
<input v-model="newExt.name" class="pv-input sm:w-40" placeholder="name" autocomplete="off" spellcheck="false" />
|
|
<input v-model="newExt.baseURL" class="pv-input flex-1" placeholder="https://plugin.example.com" autocomplete="off" spellcheck="false" />
|
|
<button class="pv-btn" :disabled="newExtBusy" @click="registerExternal">{{ newExtBusy ? "Adding…" : "Add" }}</button>
|
|
</div>
|
|
<p v-if="newExtErr" class="mt-2 text-xs font-medium text-danger">{{ newExtErr }}</p>
|
|
<p class="mt-2 text-[11px] text-muted">
|
|
A remote service that answers <span class="font-mono">GET /health</span> and
|
|
<span class="font-mono">GET /manifest</span> — added at runtime, no rebuild.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<p class="text-center font-mono text-[11px] text-muted">
|
|
PilotVault — live drone telemetry, command & control.
|
|
</p>
|
|
</div>
|
|
</template>
|