- Added "+ Register App" button and `#appDrawer` form to `src/features/admin/apps_fragments.tsx`. - Implemented action buttons (Edit, Delete) using HTML5 `data-*` attributes for safe JSON parsing. - Implemented global `openCreateAppDrawer`, `openEditAppDrawer`, `closeAppDrawer`, `handleSaveApp`, and `deleteApp` handlers in `public/admin-scripts.js`. - Fixed missing `spiffe_id` property mapping in payload. - Added corresponding Role and Invite handlers to `public/admin-scripts.js` for completeness. - Verified components render correctly via assertions in `src/features/admin/admin.test.ts`. Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
443 lines
14 KiB
JavaScript
443 lines
14 KiB
JavaScript
function showNotice(msg, isError) {
|
|
const banner = document.getElementById("status-banner");
|
|
if (!banner) return;
|
|
banner.textContent = msg;
|
|
banner.style.display = "block";
|
|
banner.style.position = "fixed";
|
|
banner.style.top = "1.25rem";
|
|
banner.style.right = "1.25rem";
|
|
banner.style.zIndex = "99999";
|
|
banner.style.minWidth = "280px";
|
|
banner.style.maxWidth = "480px";
|
|
banner.style.padding = "0.85rem 1.25rem";
|
|
banner.style.borderRadius = "var(--radius-md)";
|
|
banner.style.boxShadow = "0 10px 25px -5px rgba(0, 0, 0, 0.35)";
|
|
banner.style.fontSize = "0.9rem";
|
|
banner.style.fontWeight = "600";
|
|
banner.style.background = isError ? "var(--danger-bg)" : "var(--success-bg)";
|
|
banner.style.color = isError ? "var(--danger-text)" : "var(--success-text)";
|
|
banner.style.border = isError
|
|
? "1px solid var(--danger-border)"
|
|
: "1px solid var(--success-border)";
|
|
if (banner._timeout) clearTimeout(banner._timeout);
|
|
banner._timeout = setTimeout(() => {
|
|
banner.style.display = "none";
|
|
}, 4000);
|
|
}
|
|
globalThis.showNotice = showNotice;
|
|
|
|
function filterUsersList() {
|
|
const queryInput = document.getElementById("userSearchInput");
|
|
if (!queryInput) return;
|
|
const query = queryInput.value.toLowerCase().trim();
|
|
const rows = document.querySelectorAll(".user-row");
|
|
const cards = document.querySelectorAll(".user-card");
|
|
|
|
rows.forEach((r) => {
|
|
const text = r.getAttribute("data-search") || "";
|
|
r.style.display = text.includes(query) ? "" : "none";
|
|
});
|
|
|
|
cards.forEach((c) => {
|
|
const text = c.getAttribute("data-search") || "";
|
|
c.style.display = text.includes(query) ? "" : "none";
|
|
});
|
|
}
|
|
globalThis.filterUsersList = filterUsersList;
|
|
|
|
function filterAppsList() {
|
|
const queryInput = document.getElementById("appSearchInput");
|
|
if (!queryInput) return;
|
|
const query = queryInput.value.toLowerCase().trim();
|
|
const rows = document.querySelectorAll(".app-row");
|
|
const cards = document.querySelectorAll(".app-card");
|
|
|
|
rows.forEach((r) => {
|
|
const text = r.getAttribute("data-search") || "";
|
|
r.style.display = text.includes(query) ? "" : "none";
|
|
});
|
|
|
|
cards.forEach((c) => {
|
|
const text = c.getAttribute("data-search") || "";
|
|
c.style.display = text.includes(query) ? "" : "none";
|
|
});
|
|
}
|
|
globalThis.filterAppsList = filterAppsList;
|
|
|
|
function filterAuditLogs() {
|
|
const queryInput = document.getElementById("auditSearchInput");
|
|
if (!queryInput) return;
|
|
const query = queryInput.value.toLowerCase().trim();
|
|
const rows = document.querySelectorAll(".log-row");
|
|
const cards = document.querySelectorAll(".log-card");
|
|
|
|
rows.forEach((r) => {
|
|
const text = r.getAttribute("data-search") || "";
|
|
r.style.display = text.includes(query) ? "" : "none";
|
|
});
|
|
|
|
cards.forEach((c) => {
|
|
const text = c.getAttribute("data-search") || "";
|
|
c.style.display = text.includes(query) ? "" : "none";
|
|
});
|
|
}
|
|
globalThis.filterAuditLogs = filterAuditLogs;
|
|
|
|
async function updateStatus(userId, status, username) {
|
|
if (!confirm("Set user @" + username + " to " + status.toUpperCase() + "?")) {
|
|
return;
|
|
}
|
|
try {
|
|
const res = await fetch("/api/admin/users/" + userId + "/status", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
if (res.ok) {
|
|
showNotice("User status updated to " + status, false);
|
|
setTimeout(() => globalThis.location.reload(), 600);
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || "Failed to update status", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error updating status", true);
|
|
}
|
|
}
|
|
globalThis.updateStatus = updateStatus;
|
|
|
|
async function handleUpdateProfile(e, userId) {
|
|
e.preventDefault();
|
|
const displayNameInput = document.getElementById("displayNameInput");
|
|
const displayName = displayNameInput ? displayNameInput.value.trim() : "";
|
|
|
|
try {
|
|
const res = await fetch("/api/admin/users/" + userId + "/profile", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ displayName }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
showNotice("User display name saved successfully!", false);
|
|
setTimeout(() => globalThis.location.reload(), 600);
|
|
} else {
|
|
showNotice(data.error || "Failed to update display name", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error updating display name", true);
|
|
}
|
|
}
|
|
globalThis.handleUpdateProfile = handleUpdateProfile;
|
|
|
|
async function handleGrantAccess(e, userId) {
|
|
e.preventDefault();
|
|
const grantAppId = document.getElementById("grantAppId");
|
|
const grantRole = document.getElementById("grantRole");
|
|
if (!grantAppId || !grantRole) return;
|
|
const appId = grantAppId.value;
|
|
const role = grantRole.value;
|
|
|
|
try {
|
|
const res = await fetch("/api/admin/users/" + userId + "/grants", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ appId, role }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
showNotice("Application access granted successfully!", false);
|
|
setTimeout(() => globalThis.location.reload(), 600);
|
|
} else {
|
|
showNotice(data.error || "Failed to update application grant", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error updating grant", true);
|
|
}
|
|
}
|
|
globalThis.handleGrantAccess = handleGrantAccess;
|
|
|
|
async function revokeGrant(userId, appId, appName) {
|
|
if (!confirm('Revoke access to "' + appName + '" for this user?')) return;
|
|
try {
|
|
const res = await fetch("/api/admin/users/" + userId + "/grants/" + appId, {
|
|
method: "DELETE",
|
|
});
|
|
if (res.ok) {
|
|
showNotice("Access revoked", false);
|
|
setTimeout(() => globalThis.location.reload(), 600);
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || "Failed to revoke grant", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error revoking grant", true);
|
|
}
|
|
}
|
|
globalThis.revokeGrant = revokeGrant;
|
|
|
|
async function generateRecoveryLink(userId) {
|
|
try {
|
|
const res = await fetch("/api/admin/users/" + userId + "/recovery", {
|
|
method: "POST",
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
const link = globalThis.location.origin + "/recovery?code=" +
|
|
data.recoveryCode;
|
|
const textEl = document.getElementById("recovery-link-text");
|
|
const containerEl = document.getElementById("recovery-link-container");
|
|
if (textEl) textEl.textContent = link;
|
|
if (containerEl) containerEl.style.display = "block";
|
|
showNotice("Recovery link generated!", false);
|
|
} else {
|
|
showNotice(data.error || "Failed to generate link", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error generating recovery link", true);
|
|
}
|
|
}
|
|
globalThis.generateRecoveryLink = generateRecoveryLink;
|
|
|
|
async function revokeSession(sessionId) {
|
|
if (!confirm("Revoke this session?")) return;
|
|
try {
|
|
const res = await fetch("/api/admin/sessions/" + sessionId, {
|
|
method: "DELETE",
|
|
});
|
|
if (res.ok) {
|
|
showNotice("Session revoked", false);
|
|
setTimeout(() => globalThis.location.reload(), 600);
|
|
} else {
|
|
showNotice("Failed to revoke session", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error revoking session", true);
|
|
}
|
|
}
|
|
globalThis.revokeSession = revokeSession;
|
|
|
|
async function revokeAllSessions(userId) {
|
|
if (
|
|
!confirm(
|
|
"Revoke ALL sessions for this user? They will be immediately logged out.",
|
|
)
|
|
) return;
|
|
try {
|
|
const res = await fetch("/api/admin/users/" + userId + "/sessions", {
|
|
method: "DELETE",
|
|
});
|
|
if (res.ok) {
|
|
showNotice("All sessions revoked", false);
|
|
setTimeout(() => globalThis.location.reload(), 600);
|
|
} else {
|
|
showNotice("Failed to revoke all sessions", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error revoking sessions", true);
|
|
}
|
|
}
|
|
globalThis.revokeAllSessions = revokeAllSessions;
|
|
|
|
async function deletePasskey(userId, passkeyId) {
|
|
if (
|
|
!confirm(
|
|
"Permanently delete this device? The user will no longer be able to log in with it.",
|
|
)
|
|
) return;
|
|
try {
|
|
const res = await fetch(
|
|
"/api/admin/users/" + userId + "/passkeys/" + passkeyId,
|
|
{ method: "DELETE" },
|
|
);
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
showNotice("Passkey deleted", false);
|
|
setTimeout(() => globalThis.location.reload(), 600);
|
|
} else {
|
|
showNotice(data.error || "Failed to delete passkey", true);
|
|
}
|
|
} catch (_err) {
|
|
showNotice("Network error deleting passkey", true);
|
|
}
|
|
}
|
|
globalThis.deletePasskey = deletePasskey;
|
|
|
|
function updateRoleOptions(allRolesJson) {
|
|
const grantAppIdEl = document.getElementById("grantAppId");
|
|
if (!grantAppIdEl) return;
|
|
const appId = grantAppIdEl.value;
|
|
const roleSelect = document.getElementById("grantRole");
|
|
if (!roleSelect) return;
|
|
roleSelect.innerHTML = "";
|
|
|
|
const roles = typeof allRolesJson === "string"
|
|
? JSON.parse(allRolesJson)
|
|
: (allRolesJson || []);
|
|
const available = roles.filter((r) => !r.app_id || r.app_id === appId);
|
|
if (available.length === 0) {
|
|
const opt = document.createElement("option");
|
|
opt.value = "user";
|
|
opt.textContent = "user";
|
|
roleSelect.appendChild(opt);
|
|
return;
|
|
}
|
|
|
|
available.forEach((r) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = r.name;
|
|
opt.textContent = r.name + (r.app_id ? " (App Custom)" : " (Global)");
|
|
roleSelect.appendChild(opt);
|
|
});
|
|
}
|
|
globalThis.updateRoleOptions = updateRoleOptions;
|
|
|
|
// Slice 1: Application Registry Handlers
|
|
function openCreateAppDrawer() {
|
|
const drawer = document.getElementById("appDrawer");
|
|
if (!drawer) return;
|
|
const title = document.getElementById("appDrawerTitle");
|
|
if (title) title.textContent = "Register New Subsidiary Application";
|
|
|
|
document.getElementById("editAppId").value = "";
|
|
document.getElementById("appName").value = "";
|
|
document.getElementById("appSpiffeId").value = "";
|
|
document.getElementById("appSpiffeId").readOnly = false;
|
|
document.getElementById("appSpiffeId").style.opacity = "1";
|
|
document.getElementById("appDescription").value = "";
|
|
document.getElementById("appDomain").value = "";
|
|
document.getElementById("appIsPublic").checked = false;
|
|
document.getElementById("appBypassPaths").value = "";
|
|
document.getElementById("appAllowedCidrs").value = "";
|
|
|
|
drawer.style.display = "block";
|
|
}
|
|
globalThis.openCreateAppDrawer = openCreateAppDrawer;
|
|
|
|
function openEditAppDrawer(appJson) {
|
|
const drawer = document.getElementById("appDrawer");
|
|
if (!drawer) return;
|
|
|
|
let app;
|
|
try {
|
|
app = typeof appJson === "string" ? JSON.parse(appJson) : appJson;
|
|
} catch (e) {
|
|
showNotice("Failed to parse application data.", true);
|
|
return;
|
|
}
|
|
|
|
const title = document.getElementById("appDrawerTitle");
|
|
if (title) title.textContent = "Edit Application: " + app.name;
|
|
|
|
document.getElementById("editAppId").value = app.id || "";
|
|
document.getElementById("appName").value = app.name || "";
|
|
document.getElementById("appSpiffeId").value = app.spiffe_id || "";
|
|
document.getElementById("appSpiffeId").readOnly = true;
|
|
document.getElementById("appSpiffeId").style.opacity = "0.6";
|
|
document.getElementById("appDescription").value = app.description || "";
|
|
document.getElementById("appDomain").value = app.domain || "";
|
|
document.getElementById("appIsPublic").checked = app.is_public || false;
|
|
document.getElementById("appBypassPaths").value =
|
|
Array.isArray(app.bypass_paths)
|
|
? app.bypass_paths.join(",")
|
|
: (app.bypass_paths || "");
|
|
document.getElementById("appAllowedCidrs").value =
|
|
Array.isArray(app.allowed_cidrs)
|
|
? app.allowed_cidrs.join(",")
|
|
: (app.allowed_cidrs || "");
|
|
|
|
drawer.style.display = "block";
|
|
}
|
|
globalThis.openEditAppDrawer = openEditAppDrawer;
|
|
|
|
function closeAppDrawer() {
|
|
const drawer = document.getElementById("appDrawer");
|
|
if (drawer) drawer.style.display = "none";
|
|
}
|
|
globalThis.closeAppDrawer = closeAppDrawer;
|
|
|
|
async function handleSaveApp(e) {
|
|
e.preventDefault();
|
|
const id = document.getElementById("editAppId").value;
|
|
const name = document.getElementById("appName").value.trim();
|
|
const spiffeId = document.getElementById("appSpiffeId").value.trim();
|
|
const description = document.getElementById("appDescription").value.trim();
|
|
const domain = document.getElementById("appDomain").value.trim();
|
|
const isPublic = document.getElementById("appIsPublic").checked;
|
|
const bypassPathsRaw = document.getElementById("appBypassPaths").value.trim();
|
|
const allowedCidrsRaw = document.getElementById("appAllowedCidrs").value
|
|
.trim();
|
|
|
|
if (!name) {
|
|
showNotice("Application Name is required", true);
|
|
return;
|
|
}
|
|
if (!id && !spiffeId) {
|
|
showNotice("SPIFFE ID is required", true);
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
name,
|
|
spiffe_id: spiffeId,
|
|
description,
|
|
domain,
|
|
is_public: isPublic,
|
|
bypass_paths: bypassPathsRaw
|
|
? bypassPathsRaw.split(",").map((s) => s.trim()).filter((s) => s)
|
|
: [],
|
|
allowed_cidrs: allowedCidrsRaw
|
|
? allowedCidrsRaw.split(",").map((s) => s.trim()).filter((s) => s)
|
|
: [],
|
|
};
|
|
|
|
const url = id ? `/api/admin/apps/${id}` : "/api/admin/apps";
|
|
const method = id ? "PUT" : "POST";
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (res.ok) {
|
|
showNotice(
|
|
id
|
|
? "Application updated successfully!"
|
|
: "Application registered successfully!",
|
|
false,
|
|
);
|
|
setTimeout(() => globalThis.location.reload(), 600);
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || "Failed to save application", true);
|
|
}
|
|
} catch (err) {
|
|
showNotice("Network error saving application", true);
|
|
}
|
|
}
|
|
globalThis.handleSaveApp = handleSaveApp;
|
|
|
|
async function deleteApp(appId, appName) {
|
|
if (
|
|
!confirm(
|
|
"Permanently delete application '" + appName +
|
|
"'? This will revoke all active grants.",
|
|
)
|
|
) return;
|
|
try {
|
|
const res = await fetch("/api/admin/apps/" + appId, { method: "DELETE" });
|
|
if (res.ok) {
|
|
showNotice("Application deleted successfully", false);
|
|
setTimeout(() => globalThis.location.reload(), 600);
|
|
} else {
|
|
const data = await res.json();
|
|
showNotice(data.error || "Failed to delete application", true);
|
|
}
|
|
} catch (err) {
|
|
showNotice("Network error deleting application", true);
|
|
}
|
|
}
|
|
globalThis.deleteApp = deleteApp;
|