Nodalock
AI Security Operations
Securing your workspace…
Nodalock AI

Make AI activity accountable.

One calm command center for AI usage, policy review, and the security signals that deserve a human decision.

Policy-awareAudit-readyPrivacy-first

Welcome back

Sign in to your security operations workspace.

or continue with

Create your account

Start with a personal seat, then join or create a workspace.

Create a workspace

Set up your company’s protected AI operations space.

This code is used to lock down the workspace or restore access in case of a security emergency. Store it securely.

// ===== PERMISSIONS, ROLES, RISK LEVELS, POLICIES, DOMAINS, BILLING, AGENT, NOTIFICATIONS, MESSAGES, DEPARTMENTS, EMERGENCY, EVENT LISTENERS ===== async function loadPermissionsPage(role) { if (!isAdmin()) { toast("Only administrators can manage permissions.", "warning"); return; } if (!usingDemoData()) { try { state.permissions = await selectRows("role_permissions", "select=*&limit=1000"); state.permissionSource = "database"; } catch (error) { state.permissions = defaultPermissionRows(); state.permissionSource = "default"; toast("Permission table is unavailable. Showing the safe default matrix.", "warning"); } } state.currentPermissionRole = availableRoles().includes(role) ? role : availableRoles()[0]; renderPermissionTabs(); renderPermissionRows(); } function renderPermissionTabs() { const tabs = $("#permissionTabs"); const defaultRoles = ["admin", "manager", "viewer"]; const roles = availableRoles(); tabs.innerHTML = `${roles.map((role) => { const deletable = !defaultRoles.includes(role) && isAdmin(); return ` ${deletable ? `` : ""} `; }).join("")}`; const trash = $("#roleDragZone"); if (trash) { const hasDeletable = roles.some(r => !defaultRoles.includes(r)); trash.classList.toggle("is-visible", isAdmin() && hasDeletable); } } function renderPermissionRows() { const role = state.currentPermissionRole; const rows = currentPermissionRows(role); const map = new Map(rows.map((row) => [row.page, row.allowed])); $("#permissionList").innerHTML = ROLE_PAGES.map((page) => `
${escapeHTML(page === "policy" ? "policies" : page.replace(/-/g, " "))}Allow ${escapeHTML(role)} access to this page
`).join(""); } async function setPermission(page, allowed) { const role = state.currentPermissionRole; const existing = currentPermissionRows(role).find((row) => row.page === page); showBusy(); try { if (usingDemoData() || state.permissionSource !== "database") { if (existing) existing.allowed = allowed; else state.permissions.push({ id: `${role}-${page}-${Date.now()}`, role, page, allowed }); } else if (existing?.id) await apiFetch(`/rest/v1/role_permissions?${filterEq("id", existing.id)}`, { method: "PATCH", headers: { Prefer: "return=representation" }, body: JSON.stringify({ allowed }) }); else await apiFetch("/rest/v1/role_permissions", { method: "POST", headers: { Prefer: "return=representation" }, body: JSON.stringify({ role, page, allowed }) }); if (!usingDemoData() && state.permissionSource === "database") state.permissions = await selectRows("role_permissions", "select=*&limit=1000"); toast(`${page} ${allowed ? "enabled" : "disabled"} for ${role}.`); renderPermissionTabs(); renderPermissionRows(); refreshNavigation(); } catch (error) { toast(error.message || "Permission update failed.", "error"); renderPermissionRows(); } finally { hideBusy(); } } async function addRole() { if (!isAdmin()) { toast("Only administrators can create a role.", "warning"); return; } const raw = window.prompt("New role name (for example: auditor)"); if (!raw) return; const role = raw.trim().toLowerCase().replace(/\s+/g, "-"); if (!/^[a-z0-9-]{2,32}$/.test(role)) { toast("Use 2–32 lowercase letters, numbers, or hyphens.", "warning"); return; } if (availableRoles().includes(role)) { toast("That role already exists.", "warning"); return; } showBusy(); try { if (usingDemoData() || state.permissionSource !== "database") { ROLE_PAGES.forEach((page, index) => state.permissions.push({ id: `${role}-${page}-${index}`, role, page, allowed: false })); } else { for (const page of ROLE_PAGES) await apiFetch("/rest/v1/role_permissions", { method: "POST", headers: { Prefer: "return=representation" }, body: JSON.stringify({ role, page, allowed: false }) }); state.permissions = await selectRows("role_permissions", "select=*&limit=1000"); } state.currentPermissionRole = role; renderPermissionTabs(); renderPermissionRows(); toast(`Role “${role}” created. Its new tab is ready to configure.`); } catch (error) { toast(error.message || "Role could not be created.", "error"); } finally { hideBusy(); } } async function deleteRole(role) { if (!isAdmin()) { toast("Only administrators can delete roles.", "warning"); return; } if (["admin", "manager", "viewer"].includes(role)) { toast("Default roles cannot be deleted.", "warning"); return; } if (!window.confirm(`Delete the role "${role}" and all its permission entries? Users with this role will lose access to all pages.`)) return; showBusy(); try { if (usingDemoData() || state.permissionSource !== "database") { state.permissions = state.permissions.filter((row) => row.role !== role); } else { await apiFetch(`/rest/v1/role_permissions?${filterEq("role", role)}`, { method: "DELETE" }); state.permissions = await selectRows("role_permissions", "select=*&limit=1000"); } if (state.currentPermissionRole === role) state.currentPermissionRole = "admin"; renderPermissionTabs(); renderPermissionRows(); refreshNavigation(); toast(`Role "${role}" deleted.`); } catch (error) { toast(error.message || "Role could not be deleted.", "error"); } finally { hideBusy(); } } function initRoleDrag() { const tabs = $("#permissionTabs"); const trash = $("#roleDragZone"); if (!tabs || !trash) return; tabs.addEventListener("dragstart", (event) => { const wrap = event.target.closest("[data-role-drag]"); if (!wrap) return; wrap.style.opacity = ".45"; trash.classList.add("is-over"); event.dataTransfer.setData("text/plain", wrap.dataset.roleDrag); event.dataTransfer.effectAllowed = "move"; }); tabs.addEventListener("dragend", (event) => { const wrap = event.target.closest("[data-role-drag]"); if (wrap) wrap.style.opacity = ""; trash.classList.remove("is-over"); }); trash.addEventListener("dragover", (event) => { event.preventDefault(); trash.classList.add("is-over"); }); trash.addEventListener("dragleave", () => { trash.classList.remove("is-over"); }); trash.addEventListener("drop", async (event) => { event.preventDefault(); trash.classList.remove("is-over"); const role = event.dataTransfer.getData("text/plain"); if (role) await deleteRole(role); }); } async function loadRiskLevels() { const rows = usingDemoData() ? [] : await (async () => { try { return await selectRows("threat_config", `select=*&${filterEq("company_code", state.profile?.company_code || "PERSONAL")}&order=created_at.desc&limit=200`); } catch (_) { toast("Risk configuration is unavailable.", "warning"); return []; } })(); $("#threatBody").innerHTML = rows.length ? rows.map(item => ` ${escapeHTML(item.target_value)}${escapeHTML(item.target_type)}${escapeHTML(item.threat_level)}${canManagePolicies() ? `` : ""} `).join("") : `No custom risk rules set.`; } function openThreatModal() { if (!canManagePolicies()) { toast("Only admins and managers can set risk levels.", "warning"); return; } openModal("Add risk rule", "Override the default risk level for a specific AI tool or domain.", `
`); } async function saveThreat(form) { const payload = { company_code: state.profile?.company_code || "PERSONAL", target_type: form.type.value, target_value: form.target.value.trim().toLowerCase(), threat_level: form.level.value }; if (!payload.target_value) { toast("Enter a target.", "warning"); return; } showBusy(); try { if (usingDemoData()) { toast("Risk rules can't be saved in demo mode.", "warning"); return; } await apiFetch("/rest/v1/threat_config", { method: "POST", headers: { Prefer: "return=representation" }, body: JSON.stringify(payload) }); closeModal(); toast("Risk rule added."); await loadRiskLevels(); } catch (error) { toast(error.message || "Could not save rule.", "error"); } finally { hideBusy(); } } async function deleteThreat(id) { if (!canManagePolicies()) { toast("Only admins and managers can delete risk rules.", "warning"); return; } if (!window.confirm("Delete this risk rule?")) return; showBusy(); try { await apiFetch(`/rest/v1/threat_config?${filterEq("id", id)}`, { method: "DELETE" }); toast("Rule deleted."); await loadRiskLevels(); } catch (error) { toast(error.message || "Could not delete rule.", "error"); } finally { hideBusy(); } } async function loadDeletions() { const rows = usingDemoData() ? demoDeletions : await (async () => { try { return await selectRows("deletion_requests", "select=*&order=requested_at.desc&limit=100"); } catch (_) { toast("Deletion requests are unavailable. Showing sample requests.", "warning"); return demoDeletions; } })(); $("#deletionsBody").innerHTML = rows.length ? rows.map((request) => `${escapeHTML(request.user_email)}${escapeHTML(formatDate(request.requested_at))}${escapeHTML(request.status || "pending")}${isAdmin() && request.status === "pending" ? `
` : ""}`).join("") : `No deletion requests are waiting.`; } async function setDeletionStatus(id, status) { if (!isAdmin()) { toast("Only administrators can process deletion requests.", "warning"); return; } showBusy(); try { if (usingDemoData()) { const request = demoDeletions.find((item) => normalizeId(item.id) === normalizeId(id)); if (request) request.status = status; } else await apiFetch(`/rest/v1/deletion_requests?${filterEq("id", id)}`, { method: "PATCH", body: JSON.stringify({ status }) }); toast(`Deletion request ${status}.`); await loadDeletions(); } catch (error) { toast(error.message || "Deletion request could not be updated.", "error"); } finally { hideBusy(); } } function orderPolicies(rows) { const ids = state.policyOrder.map(normalizeId); return [...rows].sort((a, b) => { if (Boolean(a.pinned) !== Boolean(b.pinned)) return Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)); const aIndex = ids.indexOf(normalizeId(a.id)); const bIndex = ids.indexOf(normalizeId(b.id)); if (aIndex >= 0 || bIndex >= 0) return (aIndex < 0 ? Infinity : aIndex) - (bIndex < 0 ? Infinity : bIndex); return new Date(b.created_at || 0) - new Date(a.created_at || 0); }); } async function loadPolicies() { const rows = usingDemoData() ? demoPolicies : await (async () => { try { return await selectRows("policies", "select=*&order=pinned.desc,created_at.desc&limit=100"); } catch (error) { toast(isMissingTableError(error) ? "Policies table is unavailable. Showing sample policies." : "Policies could not load. Showing sample policies.", "warning"); return demoPolicies; } })(); const manageable = canManagePolicies(); const ordered = orderPolicies(rows); const list = $("#policyList"); list.innerHTML = ordered.length ? ordered.map((policy) => { const expanded = state.expandedPolicies.has(normalizeId(policy.id)); const truncated = String(policy.content || "").length > 260; return `
${policy.pinned ? `📌` : ""}
${escapeHTML(policy.content || "No policy content.")}
${truncated ? `` : ""}
Version ${escapeHTML(policy.version || 1)} · ${escapeHTML(formatDate(policy.created_at, false))}
${manageable ? `` : ""}
`; }).join("") : `

No policies have been published yet.

`; } function policyById(id) { return demoPolicies.find((item) => normalizeId(item.id) === normalizeId(id)); } async function fetchPolicy(id) { if (usingDemoData()) return policyById(id); try { const rows = await selectRows("policies", `select=*&${filterEq("id", id)}&limit=1`); return rows[0] || null; } catch (_) { return null; } } async function viewPolicy(id) { const policy = await fetchPolicy(id); if (!policy) { toast("That policy is not available.", "warning"); return; } openModal(policy.title || "Policy", `Version ${policy.version || 1} · ${formatDate(policy.created_at, false)}`, `
${escapeHTML(policy.content || "No policy content.")}
`); } async function openPolicyModal(policy = null) { if (!canManagePolicies()) { toast("Only administrators and managers can change policies.", "warning"); return; } const current = policy || { title: "", content: "", id: "" }; openModal(policy ? "Edit policy" : "Create policy", policy ? "Publish a revised, versioned policy." : "Add a clear AI governance rule for the workspace.", `
`, true); } async function savePolicy(form) { const id = form.dataset.policyId; const title = form.title.value.trim(); const content = form.content.value.trim(); if (!title || !content) { toast("A title and policy content are required.", "warning"); return; } const payload = { title, content, version: Number(form.dataset.policyVersion || 0) + 1 }; showBusy(); try { if (usingDemoData()) { if (id) { const item = policyById(id); if (item) Object.assign(item, payload); } else demoPolicies.push({ ...payload, id: Date.now(), pinned: false, created_at: new Date().toISOString() }); } else if (id) await apiFetch(`/rest/v1/policies?${filterEq("id", id)}`, { method: "PATCH", body: JSON.stringify(payload) }); else await apiFetch("/rest/v1/policies", { method: "POST", headers: { Prefer: "return=representation" }, body: JSON.stringify({ ...payload, pinned: false }) }); closeModal(); if (!id) await addNotification(`New policy published: ${title}`, "policy"); playSound("success"); toast(id ? "Policy revision published." : "Policy published."); await loadPolicies(); } catch (error) { playSound("error"); toast(error.message || "Policy could not be saved.", "error"); } finally { hideBusy(); } } async function togglePolicyPin(id) { const policy = await fetchPolicy(id); if (!policy) return; const next = !policy.pinned; showBusy(); try { if (usingDemoData()) policy.pinned = next; else await apiFetch(`/rest/v1/policies?${filterEq("id", id)}`, { method: "PATCH", body: JSON.stringify({ pinned: next }) }); toast(next ? "Policy pinned." : "Policy unpinned."); await loadPolicies(); } catch (error) { toast(error.message || "Policy pin could not be updated.", "error"); } finally { hideBusy(); } } async function deletePolicy(id, ask = true) { if (!canManagePolicies()) { toast("Only administrators and managers can change policies.", "warning"); return; } if (ask && !window.confirm("Delete this policy permanently?")) return; showBusy(); try { if (usingDemoData()) demoPolicies = demoPolicies.filter((item) => normalizeId(item.id) !== normalizeId(id)); else await apiFetch(`/rest/v1/policies?${filterEq("id", id)}`, { method: "DELETE" }); state.policyOrder = state.policyOrder.filter((item) => normalizeId(item) !== normalizeId(id)); localStorage.setItem("nodalock-policy-order", JSON.stringify(state.policyOrder)); toast("Policy deleted."); await loadPolicies(); } catch (error) { toast(error.message || "Policy could not be deleted.", "error"); } finally { hideBusy(); } } function initPolicyDrag() { const list = $("#policyList"); const trash = $("#policyTrash"); if (!list || !trash) return; list.addEventListener("dragstart", (event) => { const card = event.target.closest(".policy-card[draggable='true']"); if (!card) return; state.dragPolicyId = card.dataset.policyId; card.classList.add("dragging"); trash.classList.add("is-visible"); event.dataTransfer.effectAllowed = "move"; }); list.addEventListener("dragover", (event) => { const dragging = list.querySelector(".policy-card.dragging"); if (!dragging) return; event.preventDefault(); const siblings = $all(".policy-card:not(.dragging)", list); const next = siblings.find((card) => { const rect = card.getBoundingClientRect(); return event.clientY < rect.top + rect.height / 2; }); if (next) list.insertBefore(dragging, next); else list.appendChild(dragging); }); list.addEventListener("dragend", () => { $all(".policy-card", list).forEach((card) => card.classList.remove("dragging")); trash.classList.remove("is-visible", "is-over"); state.dragPolicyId = null; const order = $all(".policy-card", list).map((card) => card.dataset.policyId); if (order.length) { state.policyOrder = order; localStorage.setItem("nodalock-policy-order", JSON.stringify(order)); } }); trash.addEventListener("dragover", (event) => { event.preventDefault(); trash.classList.add("is-over"); }); trash.addEventListener("dragleave", () => trash.classList.remove("is-over")); trash.addEventListener("drop", async (event) => { event.preventDefault(); trash.classList.remove("is-over", "is-visible"); if (state.dragPolicyId) await deletePolicy(state.dragPolicyId, true); state.dragPolicyId = null; }); } async function loadDomains() { const email = state.user?.email || "guest@demo.nodalock.ai"; const rows = usingDemoData() ? demoDomains : await (async () => { try { return await selectRows("custom_domains", `select=*&${filterEq("company_email", email)}&order=created_at.desc&limit=200`); } catch (_) { toast("Blocked domains are unavailable. Showing sample domains.", "warning"); return demoDomains; } })(); const manage = canManagePolicies(); $("#domainsBody").innerHTML = rows.length ? rows.map((entry) => `${escapeHTML(entry.domain)}${escapeHTML(formatDate(entry.created_at))}${manage ? `` : ""}`).join("") : `No custom domains are currently blocked.`; } function openDomainModal() { if (!canManagePolicies()) { toast("Only administrators and managers can change blocked domains.", "warning"); return; } openModal("Add blocked domain", "The domain will join your custom monitoring list.", `
`); } async function saveDomain(form) { const domain = form.domain.value.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, ""); if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) { toast("Enter a valid domain such as example.com.", "warning"); return; } const companyEmail = state.user?.email || "guest@demo.nodalock.ai"; showBusy(); try { if (usingDemoData()) demoDomains.unshift({ id: Date.now(), company_email: companyEmail, domain, created_at: new Date().toISOString() }); else await apiFetch("/rest/v1/custom_domains", { method: "POST", headers: { Prefer: "return=representation" }, body: JSON.stringify({ company_email: companyEmail, domain, created_at: new Date().toISOString() }) }); closeModal(); playSound("success"); toast("Domain added to monitoring."); await loadDomains(); } catch (error) { playSound("error"); toast(error.message || "Domain could not be added.", "error"); } finally { hideBusy(); } } async function deleteDomain(id) { if (!canManagePolicies()) { toast("Only administrators and managers can change blocked domains.", "warning"); return; } if (!window.confirm("Delete this blocked domain?")) return; showBusy(); try { if (usingDemoData()) demoDomains = demoDomains.filter((entry) => normalizeId(entry.id) !== normalizeId(id)); else await apiFetch(`/rest/v1/custom_domains?${filterEq("id", id)}`, { method: "DELETE" }); toast("Domain deleted."); await loadDomains(); } catch (error) { toast(error.message || "Domain could not be deleted.", "error"); } finally { hideBusy(); } } function renderPricing() { const root = $("#pricingGrid"); const currency = CURRENCY[state.currentCurrency]; const yearly = state.billingPeriod === "yearly"; const price = (monthly) => Math.round(monthly * currency.rate * (yearly ? 10 : 1)); const cadence = yearly ? "/ user / year" : "/ user / month"; const cards = [{ key: "starter", label: "For focused teams", name: "Starter", price: price(7), features: ["Up to 25 users", "AI detection", "Core dashboard", "Email support"] }, { key: "pro", label: "For growing programs", name: "Pro", price: price(10), featured: true, features: ["Unlimited users", "Real-time review queue", "Policy center", "Department mapping", "Priority support"] }, { key: "enterprise", label: "For complex environments", name: "Enterprise", price: null, features: ["Everything in Pro", "SSO and SAML", "API access", "Custom integrations", "Dedicated support"] }]; root.innerHTML = cards.map((card) => `
${card.featured ? `RECOMMENDED` : ""}
${card.label}

${card.name}

${card.price === null ? "Custom" : `${currency.symbol}${card.price}`}${card.price === null ? "built for your program" : cadence}
${card.key === "enterprise" ? `Contact sales` : ``}
`).join(""); $all("[data-action='billing-period']").forEach((button) => button.classList.toggle("is-active", button.dataset.period === state.billingPeriod)); } function checkout(plan) { const suffix = state.billingPeriod === "yearly" ? "Yearly" : "Monthly"; const link = STRIPE_LINKS[`${plan}${suffix}`]; if (!link || link.includes("PLACEHOLDER")) { toast("The payment link has not been configured yet.", "warning"); return; } window.open(link, "_blank", "noopener,noreferrer"); } function renderAgentCards() { const platforms = [ { key: "windows", name: "Windows", copy: "MSI package for managed Windows fleets.", action: "Download", icon: `` }, { key: "mac", name: "macOS", copy: "Signed macOS package for device management.", action: "Download", icon: `` }, { key: "linux", name: "Linux", copy: "Package support for modern Linux endpoints.", action: "Download", icon: `` }, { key: "android", name: "Android", copy: "Mobile endpoint coverage for Android teams.", action: "Install", icon: `` } ]; $("#agentGrid").innerHTML = platforms.map((platform) => `
${platform.icon}

${platform.name}

${platform.copy}

`).join(""); } function showAgentModal(platform) { const names = { windows: "Windows", mac: "macOS", linux: "Linux", android: "Android" }; const name = names[platform] || "this platform"; openModal(`Nodalock agent for ${name}`, "Endpoint installers are issued by your workspace administrator.", `
1 · DownloadChoose the approved ${name} installer from your software distribution portal.
2 · EnrollEnter your company code: ${escapeHTML(state.profile?.company_code || state.company?.company_code || "Provided by your administrator")}
3 · VerifyReturn to Live Sessions after the first signal appears.
`); } async function uploadMessageFile(file) { const limit = getFileSizeLimit(); if (limit !== Infinity && file.size > limit) { const maxMB = Math.round(limit / (1024 * 1024)); throw new Error(`File too large. Your ${state.company?.plan || "starter"} plan allows up to ${maxMB}MB.`); } const path = `${Date.now()}-${file.name}`; const { error } = await db.storage.from("message-attachments").upload(path, file); if (error) throw error; const { data } = db.storage.from("message-attachments").getPublicUrl(path); return data.publicUrl; } async function addNotification(message, type = "general", recipientEmail = null, attachmentUrl = null) { const targetEmail = recipientEmail || state.user?.email; if (!targetEmail) return; const item = { id: Date.now(), user_email: targetEmail, sender_email: state.user?.email || null, message, type, read: false, deleted: false, saved: false, pinned: false, attachment_url: attachmentUrl, created_at: new Date().toISOString(), recipient_email: targetEmail }; if (usingDemoData()) { state.demoNotifications.unshift(item); await updateNotificationBadge(); playSound(item.type === "message" ? "reply_received" : "notification"); return; } try { await apiFetch("/rest/v1/notifications", { method: "POST", headers: { Prefer: "return=representation" }, body: JSON.stringify(item) }); await updateNotificationBadge(); playSound(item.type === "message" ? "reply_received" : "notification"); } catch (_) { /* Notifications should never block the primary workflow. */ } } async function updateNotificationBadge() { const badge = $("#notificationBadge"); if (!badge || !state.user?.email) return; try { let rows; if (usingDemoData()) { rows = state.demoNotifications.filter((item) => !item.read && !item.deleted && (item.user_email === state.user.email || item.recipient_email === state.user.email)); } else { const email = encodeURIComponent(state.user.email); rows = await selectRows("notifications", `select=id&or=(user_email.eq.${email},recipient_email.eq.${email})&read=eq.false&deleted=eq.false&limit=100`); } badge.textContent = String(rows.length); badge.hidden = rows.length === 0; } catch (_) { badge.hidden = true; } } async function loadNotifications() { const list = $("#notificationsList"); if (!state.user?.email) { list.innerHTML = `

Sign in to view notifications.

`; return; } let rows; try { rows = usingDemoData() ? state.demoNotifications.filter((item) => (item.user_email === state.user.email || item.recipient_email === state.user.email) && !item.deleted) : await selectRows("notifications", `select=*&or=(user_email.eq.${encodeURIComponent(state.user.email)},recipient_email.eq.${encodeURIComponent(state.user.email)})&deleted=eq.false&order=created_at.desc&limit=50`); } catch (_) { rows = state.demoNotifications.filter((item) => item.user_email === state.user.email && !item.deleted); toast("Notifications could not be loaded from the workspace.", "warning"); } state.notifications = rows; list.innerHTML = rows.length ? `
${rows.map((notification) => `
${notification.read ? "Read" : "New"}
`).join("")}
` : `

No notifications yet.

`; try { if (usingDemoData()) state.demoNotifications.forEach((item) => { if (item.user_email === state.user.email) item.read = true; }); else await apiFetch(`/rest/v1/notifications?${filterEq("user_email", state.user.email)}&read=eq.false`, { method: "PATCH", body: JSON.stringify({ read: true }) }); } catch (_) { /* The list is still useful if marking read is disallowed by policy. */ } await updateNotificationBadge(); } function openNotification(id) { const notification = state.notifications.find((item) => normalizeId(item.id) === normalizeId(id)); if (!notification) return; if (notification.type === "message") { const sender = notification.sender_email || "Admin"; openModal("Message", `From: ${escapeHTML(sender)}`, `
Message${escapeHTML(notification.message)}
`); } else { navigate(notification.type === "policy" ? "policy" : notification.type === "review" ? "review" : "dashboard"); } } async function deleteNotification(id) { if (!window.confirm("Delete this notification?")) return; showBusy(); try { if (usingDemoData()) { state.demoNotifications = state.demoNotifications.filter(n => n.id != id); } else { await apiFetch(`/rest/v1/notifications?${filterEq("id", id)}`, { method: "PATCH", body: JSON.stringify({ deleted: true }) }); } await loadNotifications(); } catch (error) { toast(error.message || "Could not delete notification.", "error"); } finally { hideBusy(); } } function openMessageModal(email) { if (!canSendMessage()) { toast("You don't have permission to send messages.", "warning"); return; } const user = userByEmail(email); const displayName = user?.name || email; openModal(`Message ${escapeHTML(displayName)}`, "Send a direct message.", `

Max file size: ${getPlanMaxDisplay()}

`); } async function sendMessage(form) { const recipient = form.dataset.recipient; const text = form.message.value.trim(); const fileInput = $("#messageAttachment"); if (!text && !fileInput?.files[0]) { toast("Write a message or attach a file.", "warning"); return; } showBusy(); try { let attachmentUrl = null; if (fileInput?.files[0]) attachmentUrl = await uploadMessageFile(fileInput.files[0]); await addNotification(text || "(File attachment)", "message", recipient, attachmentUrl); playSound("message_sent"); closeModal(); toast("Message sent."); } catch (error) { toast(error.message || "Could not send message.", "error"); } finally { hideBusy(); } } function openReplyMessage(email, original) { if (!canManageUsers()) { toast("You don't have permission to reply.", "warning"); return; } openModal(`Reply to ${escapeHTML(email)}`, `Original: ${escapeHTML(original)}`, `
`); } async function sendReply(form) { const recipient = form.dataset.recipient; const text = form.reply.value.trim(); if (!text) { toast("Write a reply.", "warning"); return; } await addNotification(text, "message", recipient); playSound("message_sent"); closeModal(); toast("Reply sent."); } async function loadMessages() { const list = $("#messagesList"); if (!state.user?.email) { list.innerHTML = `

Sign in to view messages.

`; return; } const email = state.user.email; let rows; try { if (usingDemoData()) { rows = state.demoNotifications.filter(n => n.type === "message" && (n.user_email === email || n.recipient_email === email) && !n.deleted); } else { rows = await selectRows("notifications", `select=*&or=(user_email.eq.${encodeURIComponent(email)},recipient_email.eq.${encodeURIComponent(email)})&type=eq.message&deleted=eq.false&order=created_at.desc&limit=100`); } } catch (_) { rows = state.demoNotifications.filter(n => n.type === "message" && (n.user_email === email || n.recipient_email === email) && !n.deleted); toast("Messages could not be loaded.", "warning"); } state.notifications = rows; list.innerHTML = rows.length ? `
${rows.map(msg => `
${escapeHTML(msg.message)}

From: ${escapeHTML(msg.sender_email || "Admin")} · ${escapeHTML(formatDate(msg.created_at))}

${msg.sender_email === email ? `` : ""}
`).join("")}
` : `

No messages yet.

`; } async function toggleMessageFlag(id, field) { showBusy(); try { const target = state.notifications.find(n => normalizeId(n.id) === normalizeId(id)); if (!target) return; const next = !target[field]; if (usingDemoData()) { target[field] = next; } else { await apiFetch(`/rest/v1/notifications?${filterEq("id", id)}`, { method: "PATCH", body: JSON.stringify({ [field]: next }) }); } await loadMessages(); } catch (error) { toast(error.message || "Could not update message.", "error"); } finally { hideBusy(); } } async function loadDepartments() { try { return await selectRows("departments", `select=*&${filterEq("company_code", state.profile?.company_code || "PERSONAL")}&order=name.asc`); } catch (_) { return []; } } async function openDepartmentsModal() { if (!canManageUsers()) { toast("Only admins and managers can manage departments.", "warning"); return; } const depts = await loadDepartments(); const deptCounts = {}; state.directory.forEach(u => { if (u.department) deptCounts[u.department] = (deptCounts[u.department] || 0) + 1; }); openModal("Manage departments", "Add or remove department options.", `
${depts.length ? depts.map(d => `
${escapeHTML(d.name)}

${deptCounts[d.name] || 0} user${(deptCounts[d.name] || 0) === 1 ? "" : "s"}

`).join("") : '

No custom departments yet.

'}
`); } async function addDepartment(form) { const name = form.department.value.trim(); if (!name) { toast("Enter a department name.", "warning"); return; } showBusy(); try { const payload = { company_code: state.profile?.company_code || "PERSONAL", name }; await apiFetch("/rest/v1/departments", { method: "POST", headers: { Prefer: "return=representation" }, body: JSON.stringify(payload) }); toast("Department added."); await openDepartmentsModal(); } catch (error) { toast(error.message || "Could not add department.", "error"); } finally { hideBusy(); } } async function deleteDepartment(id) { if (!window.confirm("Delete this department?")) return; showBusy(); try { await apiFetch(`/rest/v1/departments?${filterEq("id", id)}`, { method: "DELETE" }); toast("Department deleted."); await openDepartmentsModal(); } catch (error) { toast(error.message || "Could not delete department.", "error"); } finally { hideBusy(); } } function viewProfilePicture(email) { const pic = profilePicture(email); const name = userByEmail(email)?.name || email; let content = ''; if (String(pic || "").startsWith("preset:")) { const idx = parseInt(pic.split(":")[1], 10); content = `
${avatarSvg(idx, 260)}
`; } else if (String(pic || "").startsWith("data:image/")) { content = `
${escapeAttr(name)}
`; } else { const initials = getInitials(name); content = `
${escapeHTML(initials)}
`; } const fullscreenBtn = String(pic || "").startsWith("data:image/") ? `` : ''; openModal("Profile picture", `${escapeHTML(name)}`, ` ${content} `, true); } function toggleProfileZoom() { const img = document.getElementById('profilePicZoomable'); if (!img) return; const isZoomed = img.style.transform === 'scale(1.8)'; img.style.transform = isZoomed ? 'scale(1)' : 'scale(1.8)'; img.style.cursor = isZoomed ? 'zoom-in' : 'zoom-out'; } function openProfileFullscreen(email) { const pic = profilePicture(email); if (!String(pic || "").startsWith("data:image/")) { toast("Fullscreen only available for custom images.", "warning"); return; } const name = userByEmail(email)?.name || email; const fullscreenDiv = document.createElement('div'); fullscreenDiv.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.92);display:flex;align-items:center;justify-content:center;cursor:zoom-out;'; fullscreenDiv.innerHTML = `${escapeAttr(name)}`; fullscreenDiv.addEventListener('click', () => fullscreenDiv.remove()); document.body.appendChild(fullscreenDiv); } function openModal(title, copy, body, wide = false) { $("#modalTitle").textContent = title; $("#modalCopy").textContent = copy || ""; $("#modalBody").innerHTML = body; $("#modalCard").classList.toggle("modal-wide", wide); $("#modalLayer").classList.add("is-open"); } function closeModal() { $("#modalLayer").classList.remove("is-open"); $("#modalBody").innerHTML = ""; } function openOffender(email) { const rows = [...state.reviewRows, ...state.sessionRows, ...state.dashboardRows, ...demoSessions]; const session = rows.find((row) => row.user_email === email); if (session) openSessionDetail(session.id); else toast("No session details are currently available for this user.", "warning"); } function openAccountDeletionRequest() { openModal("Request account data deletion", "This sends a privacy request to your workspace administrator. Your login remains available until the request is approved.", `
`); } async function submitAccountDeletionRequest(form) { if (form.confirmation.value !== "DELETE") { toast("Type DELETE exactly to confirm the request.", "warning"); return; } const email = state.user?.email; if (!email) return; showBusy(); try { if (usingDemoData()) demoDeletions.unshift({ id: Date.now(), user_email: email, requested_at: new Date().toISOString(), status: "pending" }); else await apiFetch("/rest/v1/deletion_requests", { method: "POST", headers: { Prefer: "return=representation" }, body: JSON.stringify({ user_email: email, requested_at: new Date().toISOString(), status: "pending" }) }); closeModal(); toast("Your deletion request has been sent for review."); } catch (error) { toast(error.message || "Deletion request could not be submitted.", "error"); } finally { hideBusy(); } } async function exportSessions() { const rows = usingDemoData() ? demoSessions.slice() : await (async () => { try { return await selectRows("ai_sessions", "select=*&order=created_at.desc&limit=10000"); } catch (_) { return demoSessions.slice(); } })(); if (!rows.length) { toast("No sessions are available to export.", "warning"); return; } const cell = (value) => `"${String(value ?? "").replace(/"/g, '""')}"`; const heading = ["Observed", "User", "Department", "Tool", "Risk", "Risk score", "Duration minutes", "Status", "Data signal"]; const content = [heading.map(cell).join(","), ...rows.map((row) => [row.created_at, row.user_email, userDepartment(row), row.tool_name, row.risk_level, row.risk_score, row.session_duration_minutes, row.review_status || "pending", row.data_snippet].map(cell).join(","))].join("\n"); const url = URL.createObjectURL(new Blob([content], { type: "text/csv;charset=utf-8" })); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `nodalock-sessions-${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); toast(`Exported ${rows.length} session records.`); } function setPieSize(size) { state.pieSize = size; const dimensions = { small: "220px", normal: "300px", large: "410px" }; document.documentElement.style.setProperty("--pie-size", dimensions[size] || dimensions.normal); window.setTimeout(() => { state.charts.tools?.resize(); }, 70); } function expandPie() { const chart = state.charts.tools; if (!chart) { toast("The tool usage chart is not ready yet.", "warning"); return; } const source = { labels: [...chart.data.labels], datasets: chart.data.datasets.map((dataset) => ({ data: [...dataset.data], backgroundColor: [...dataset.backgroundColor], borderColor: dataset.borderColor, borderWidth: dataset.borderWidth })) }; openModal("AI tool usage", "A larger look at the monitored tool distribution.", `
`, true); window.setTimeout(() => { const canvas = $("#fullToolChart"); if (!canvas || !window.Chart) return; state.charts.fullscreen?.destroy(); const colors = chartColors(); state.charts.fullscreen = new Chart(canvas, { type: "doughnut", data: source, options: { responsive: true, maintainAspectRatio: false, cutout: "64%", plugins: { legend: { position: "bottom", labels: { color: colors.text, padding: 15 } } } } }); }, 20); } async function checkForUpdates() { try { const response = await fetch(`${window.location.origin}/version.json`, { cache: "no-store" }); if (!response.ok) throw new Error("No update manifest"); const version = await response.json(); toast(version.version ? `Version ${version.version} is available.` : "You are using the latest workspace console."); } catch (_) { toast("You are using the latest workspace console."); } } // Emergency recovery functions function openRecoveryModal() { if (usingDemoData()) { openModal("Emergency recovery", "Use demo recovery code DEMO-RECOVERY.", `
`); return; } if (!state.company?.recovery_code) { openModal("Set recovery code", "Create an emergency recovery code for your workspace.", `
`); return; } openModal("Emergency recovery", "Enter the recovery code to unlock security actions.", `
`); } async function setRecoveryCode(form) { const code = form.recoveryCode.value.trim(); if (code.length < 6) { toast("Use at least 6 characters.", "warning"); return; } showBusy(); try { const companyCode = state.profile?.company_code || state.company?.company_code; if (!companyCode) throw new Error("No company code found."); await apiFetch(`/rest/v1/companies?${filterEq("company_code", companyCode)}`, { method: "PATCH", body: JSON.stringify({ recovery_code: code }) }); state.company.recovery_code = code; closeModal(); playSound("success"); toast("Recovery code set successfully."); } catch (error) { toast(error.message || "Could not set recovery code.", "error"); } finally { hideBusy(); } } async function verifyRecoveryCode(form) { const code = form.code.value; if (!code) return; showBusy(); try { if (usingDemoData()) { if (code === "DEMO-RECOVERY") { openEmergencyOptions(); } else { toast("Invalid recovery code.", "error"); } } else { const rows = await selectRows("companies", `select=recovery_code&${filterEq("company_code", state.profile?.company_code || state.company?.company_code)}&limit=1`); const stored = rows[0]?.recovery_code; if (stored && stored === code) { openEmergencyOptions(); } else { toast("Invalid recovery code.", "error"); } } } catch (error) { toast(error.message || "Could not verify recovery code.", "error"); } finally { hideBusy(); } } function openEmergencyOptions() { closeModal(); openModal("Emergency actions", "Choose a security action to perform.", `
`); } async function lockAllUsers() { if (!window.confirm("This will deactivate all users except you (the current admin). Continue?")) return; showBusy(); try { if (usingDemoData()) { state.directory.forEach(u => { if (u.email !== state.user.email) u.status = "inactive"; }); } else { await apiFetch("/rest/v1/user_departments?status=eq.active", { method: "PATCH", headers: { Prefer: "return=minimal" }, body: JSON.stringify({ status: "inactive" }) }); await apiFetch(`/rest/v1/user_departments?${filterEq("email", state.user.email)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: JSON.stringify({ status: "active" }) }); } playSound("warning"); toast("All other users have been locked out."); closeModal(); await loadUsers(); } catch (error) { toast(error.message || "Could not lock users.", "error"); } finally { hideBusy(); } } async function restoreAdmin() { if (!window.confirm("This will grant your account admin role. Continue?")) return; showBusy(); try { if (usingDemoData()) { const me = state.directory.find(u => u.email === state.user.email); if (me) me.role = "admin"; } else { await apiFetch(`/rest/v1/user_departments?${filterEq("email", state.user.email)}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: JSON.stringify({ role: "admin" }) }); } playSound("success"); toast("Your admin access has been restored. Please refresh."); closeModal(); } catch (error) { toast(error.message || "Could not restore admin access.", "error"); } finally { hideBusy(); } } async function initAuth() { loadThemePreference(); applySavedStatOrder(); initStatDrag(); initPolicyDrag(); initRoleDrag(); renderPricing(); renderAgentCards(); window.setTimeout(() => $("#splashScreen").classList.add("is-leaving"), 1000); try { const { data: { session } } = await db.auth.getSession(); if (session?.user) await beginAuthenticated(session.user); else showAuth(); } catch (_) { showAuth(); } db.auth.onAuthStateChange((event, session) => { if (event === "SIGNED_IN" && session?.user && state.mode !== "authenticated") beginAuthenticated(session.user); if (event === "SIGNED_OUT" && !state.expectedSignOut && state.mode === "authenticated") resetToLogin(); if (event === "SIGNED_OUT") state.expectedSignOut = false; }); window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { if (localStorage.getItem("nodalock-theme") === "system") setTheme("system", false); }); const languageSelect = $("#languageSelect"); if (languageSelect) { languageSelect.value = state.language; languageSelect.addEventListener("change", () => { state.language = languageSelect.value; localStorage.setItem("nodalock-language", state.language); applyLanguageAndRefresh(); }); } const soundToggle = $("#soundToggle"); if (soundToggle) { soundToggle.checked = localStorage.getItem("nodalock-sound") !== "off"; soundToggle.addEventListener("change", () => { localStorage.setItem("nodalock-sound", soundToggle.checked ? "on" : "off"); if (soundToggle.checked) playSound("click"); }); } applyTranslations(); } document.addEventListener("click", async (event) => { if (event.target === $("#modalLayer")) { closeModal(); return; } const pageTrigger = event.target.closest("[data-page]"); if (pageTrigger) { await navigate(pageTrigger.dataset.page); return; } const target = event.target.closest("[data-action]"); if (!target) return; const action = target.dataset.action; if (action === "show-auth") showAuthView(target.dataset.authView); else if (action === "guest-preview") guestPreview(); else if (action === "forgot-password") openForgotPasswordModal(); else if (action === "oauth") signInOAuth(target.dataset.provider); else if (action === "signout") signOut(); else if (action === "toggle-sidebar") $("#sidebar").classList.toggle("is-open"); else if (action === "set-theme") setTheme(target.dataset.theme); else if (action === "set-accent") setAccent(target.dataset.accent); else if (action === "refresh-dashboard") loadDashboard(); else if (action === "export-sessions") exportSessions(); else if (action === "set-time") { state.timeRange = target.dataset.time; $all("[data-action='set-time']").forEach((button) => button.classList.toggle("is-active", button === target)); loadDashboard(); } else if (action === "pie-size") setPieSize(target.dataset.size); else if (action === "expand-pie") expandPie(); else if (action === "refresh-sessions") loadSessions(); else if (action === "delete-selected-sessions") deleteSessions(Array.from(state.selectedSessions)); else if (action === "clear-session-selection") { state.selectedSessions.clear(); $("#selectAllSessions").checked = false; renderSessions(); } else if (action === "show-session") openSessionDetail(target.dataset.id); else if (action === "review-session") reviewSession(target.dataset.id, target.dataset.status); else if (action === "open-justification") openJustification(target.dataset.id); else if (action === "refresh-review") loadReview(); else if (action === "approve-selected-reviews") approveSelectedReviews(); else if (action === "refresh-violations") loadViolations(); else if (action === "offender-action") offenderAction(target.dataset.email, target.dataset.response); else if (action === "open-user-offender") openOffender(target.dataset.email); else if (action === "new-user") openUserModal(); else if (action === "edit-user") openUserModal(state.directory.find((entry) => normalizeId(entry.id) === normalizeId(target.dataset.id)) || null); else if (action === "edit-own-profile") openUserModal(null, true); else if (action === "toggle-user-status") toggleUserStatus(target.dataset.id); else if (action === "delete-user") deleteUser(target.dataset.id); else if (action === "message-user") openMessageModal(target.dataset.email); else if (action === "send-reset-email") adminSendPasswordReset(target.dataset.email); else if (action === "manage-departments") openDepartmentsModal(); else if (action === "delete-department") deleteDepartment(target.dataset.id); else if (action === "new-company") openCompanyModal(); else if (action === "edit-company") { if (usingDemoData()) { openCompanyModal(demoCompanies.find((entry) => normalizeId(entry.id) === normalizeId(target.dataset.id)) || null); } else { try { const rows = await selectRows("companies", `select=*&${filterEq("id", target.dataset.id)}&limit=1`); if (rows[0]) openCompanyModal(rows[0]); else toast("That company record is no longer available.", "warning"); } catch (error) { toast(error.message || "Company details could not be loaded.", "error"); } } } else if (action === "delete-company") deleteCompany(target.dataset.id); else if (action === "select-role") { state.currentPermissionRole = target.dataset.role; renderPermissionTabs(); renderPermissionRows(); } else if (action === "add-role") addRole(); else if (action === "delete-role") deleteRole(target.dataset.role); else if (action === "new-threat") openThreatModal(); else if (action === "delete-threat") deleteThreat(target.dataset.id); else if (action === "refresh-deletions") loadDeletions(); else if (action === "deletion-status") setDeletionStatus(target.dataset.id, target.dataset.status); else if (action === "refresh-notifications") await loadNotifications(); else if (action === "open-notification") openNotification(target.dataset.id); else if (action === "delete-notification") deleteNotification(target.dataset.id); else if (action === "refresh-messages") await loadMessages(); else if (action === "pin-message") toggleMessageFlag(target.dataset.id, "pinned"); else if (action === "save-message") toggleMessageFlag(target.dataset.id, "saved"); else if (action === "reply-message") openReplyMessage(target.dataset.email, target.dataset.original); else if (action === "new-policy") openPolicyModal(); else if (action === "view-policy") viewPolicy(target.dataset.id); else if (action === "toggle-policy") { const key = normalizeId(target.dataset.id); state.expandedPolicies.has(key) ? state.expandedPolicies.delete(key) : state.expandedPolicies.add(key); loadPolicies(); } else if (action === "edit-policy") { const policy = await fetchPolicy(target.dataset.id); if (policy) openPolicyModal(policy); } else if (action === "pin-policy") togglePolicyPin(target.dataset.id); else if (action === "delete-policy") deletePolicy(target.dataset.id); else if (action === "new-domain") openDomainModal(); else if (action === "delete-domain") deleteDomain(target.dataset.id); else if (action === "billing-period") { state.billingPeriod = target.dataset.period; renderPricing(); } else if (action === "checkout") checkout(target.dataset.plan); else if (action === "agent-download") showAgentModal(target.dataset.platform); else if (action === "agent-unavailable") { closeModal(); toast("Your installer request has been recorded. Contact your workspace administrator for the secure package."); } else if (action === "request-account-deletion") openAccountDeletionRequest(); else if (action === "check-updates") checkForUpdates(); else if (action === "install-pwa") { if (state.deferredPrompt) { state.deferredPrompt.prompt(); await state.deferredPrompt.userChoice; state.deferredPrompt = null; $("#installButton").hidden = true; } else toast("The app is already installed or your browser does not support installation.", "warning"); } else if (action === "pick-avatar") { $("#profilePicture").value = target.dataset.avatar; $all(".avatar-choice").forEach((choice) => choice.classList.toggle("is-selected", choice === target)); $("#avatarFileName").textContent = "Preset selected"; } else if (action === "open-recovery-modal") openRecoveryModal(); else if (action === "lock-all-users") lockAllUsers(); else if (action === "restore-admin") restoreAdmin(); else if (action === "view-profile-picture") viewProfilePicture(target.dataset.email); else if (action === "open-profile-fullscreen") openProfileFullscreen(target.dataset.email); else if (action === "close-modal") closeModal(); }); document.addEventListener("change", async (event) => { if (event.target.id === "dashboardDepartment") loadDashboard(); if (event.target.id === "userDepartmentFilter") renderUsers(); if (event.target.id === "sessionRisk" || event.target.id === "sessionLimit") { if (event.target.id === "sessionLimit") await loadSessions(); else renderSessions(); } if (event.target.id === "currencySelect") { state.currentCurrency = event.target.value; renderPricing(); } if (event.target.id === "selectAllSessions") toggleAllSessions(event.target.checked); if (event.target.id === "selectAllReviews") toggleAllReviews(event.target.checked); if (event.target.matches("[data-select-session]")) { event.target.checked ? state.selectedSessions.add(normalizeId(event.target.dataset.selectSession)) : state.selectedSessions.delete(normalizeId(event.target.dataset.selectSession)); updateSessionBulkBar(); } if (event.target.matches("[data-select-review]")) { event.target.checked ? state.selectedReviews.add(normalizeId(event.target.dataset.selectReview)) : state.selectedReviews.delete(normalizeId(event.target.dataset.selectReview)); } if (event.target.matches("[data-permission-page]")) setPermission(event.target.dataset.permissionPage, event.target.checked); if (event.target.id === "avatarUpload") { try { const data = await compressAvatar(event.target.files?.[0]); if (data) { $("#profilePicture").value = data; $("#avatarFileName").textContent = event.target.files[0].name; $all(".avatar-choice").forEach((choice) => choice.classList.remove("is-selected")); } } catch (error) { toast(error.message || "Could not read that image.", "error"); } } }); let searchTimer = null; document.addEventListener("input", (event) => { if (event.target.id === "sessionSearch" || event.target.id === "userSearch") { window.clearTimeout(searchTimer); searchTimer = window.setTimeout(event.target.id === "userSearch" ? renderUsers : renderSessions, 120); } }); document.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.target; if (form.id === "signInForm") signIn(form); else if (form.id === "forgotPasswordForm") sendPasswordReset(form); else if (form.id === "signUpForm") signUp(form); else if (form.id === "companyCreateForm") createCompanyWorkspace(form); else if (form.id === "userForm") saveUser(form); else if (form.id === "companyForm") saveCompany(form); else if (form.id === "policyForm") savePolicy(form); else if (form.id === "domainForm") saveDomain(form); else if (form.id === "justificationForm") saveJustification(form); else if (form.id === "accountDeletionForm") submitAccountDeletionRequest(form); else if (form.id === "recoveryForm") verifyRecoveryCode(form); else if (form.id === "setRecoveryForm") setRecoveryCode(form); else if (form.id === "threatForm") saveThreat(form); else if (form.id === "messageForm") sendMessage(form); else if (form.id === "replyForm") sendReply(form); else if (form.id === "departmentForm") addDepartment(form); }); document.addEventListener("dragstart", (event) => { const row = event.target.closest("#sessionsBody tr[data-session-row]"); if (!row) return; state.dragSessionId = row.dataset.sessionRow; row.style.opacity = ".45"; $("#sessionTrash").classList.add("is-visible"); event.dataTransfer.effectAllowed = "move"; }); document.addEventListener("dragend", (event) => { const row = event.target.closest("#sessionsBody tr[data-session-row]"); if (row) row.style.opacity = ""; $("#sessionTrash").classList.remove("is-visible", "is-over"); state.dragSessionId = null; }); $("#sessionTrash").addEventListener("dragover", (event) => { event.preventDefault(); $("#sessionTrash").classList.add("is-over"); }); $("#sessionTrash").addEventListener("dragleave", () => $("#sessionTrash").classList.remove("is-over")); $("#sessionTrash").addEventListener("drop", async (event) => { event.preventDefault(); $("#sessionTrash").classList.remove("is-visible", "is-over"); const ids = state.selectedSessions.size ? Array.from(state.selectedSessions) : (state.dragSessionId ? [state.dragSessionId] : []); state.dragSessionId = null; await deleteSessions(ids); }); window.addEventListener("beforeinstallprompt", (event) => { event.preventDefault(); state.deferredPrompt = event; $("#installButton").hidden = false; }); initAuth(); // Failsafe: always hide splash after 2 seconds no matter what setTimeout(() => { const splash = document.getElementById('splashScreen'); if (splash) { splash.classList.add('is-leaving'); setTimeout(() => { splash.style.display = 'none'; }, 600); } const auth = document.getElementById('authScreen'); if (auth) auth.style.display = 'grid'; const app = document.getElementById('appShell'); if (app) { app.hidden = false; app.style.display = 'block'; } }, 2000);