1438 lines
50 KiB
JavaScript
1438 lines
50 KiB
JavaScript
/* ════════════════════════════════════════════════════════════════
|
|
Derez — User Portal
|
|
════════════════════════════════════════════════════════════════ */
|
|
|
|
/* ─── API Base & Endpoints ───────────────────────────────────── */
|
|
const API_BASE = "https://n8n.derez.ai/webhook";
|
|
|
|
const AUTH_URL = `${API_BASE}/account/login`; // POST (sign in), DELETE (sign out)
|
|
const AGENTS_URL = `${API_BASE}/agent/list`; // GET ?email=
|
|
const AGENT_INFO_URL = `${API_BASE}/agent/info`; // GET ?uuid= | POST {uuid,key,value}
|
|
const KEYS_URL = `${API_BASE}/agent/key`; // GET ?uuid=
|
|
const PASSWORD_URL = `${API_BASE}/agent/password`; // POST {uuid,email,password}
|
|
const RESTART_URL = `${API_BASE}/agent/service/restart`; // POST {uuid,email,action:"restart"}
|
|
const BACKUP_URL = `${API_BASE}/agent/service/backup`; // GET ?uuid= | POST {uuid} | PUT restore
|
|
const CONTRACT_URL = `${API_BASE}/account/contract`; // GET ?uuid=
|
|
const ACCOUNT_PASSWORD_URL = `${API_BASE}/account/password/change`; // POST {email,password}
|
|
const PW_RESET_URL = `${API_BASE}/account/password/init_change`; // POST {email}
|
|
const SIGNUP_URL = `${API_BASE}/etc/signup`; // POST {email,password,agent,location}
|
|
const COUPON_URL = `${API_BASE}/etc/coupon`; // POST {code}
|
|
const CHAT_URL = `${API_BASE}/etc/chat`; // POST {message,sessionId,email}
|
|
const REFERRAL_URL = `${API_BASE}/etc/referral`; // POST {referrer,friend_name,friend_email,uuid}
|
|
// WIZZARD=false is written via AGENT_INFO_URL (POST { uuid, key, value })
|
|
const CREDITS_URL = `${API_BASE}/payment/credits`; // hidden form POST → 302 Stripe redirect
|
|
const INVOICES_URL = `${API_BASE}/invoices`; // GET ?email=
|
|
const INVOICE_EMAIL_URL = `${API_BASE}/user`; // POST {email, invoice_email}
|
|
const CONTRACT_EXTEND_URL = "https://derez.ai"; // ← replace with Stripe payment link
|
|
const CONTRACT_CANCEL_URL = "https://derez.ai"; // ← replace with Stripe cancellation link
|
|
|
|
/* ─── State ─────────────────────────────────────────────────── */
|
|
let currentEmail = null;
|
|
let currentSession = null;
|
|
let couponApplied = false;
|
|
let agents = [];
|
|
let activeUUID = null;
|
|
let activeAgentInfo = null;
|
|
let chatId = null; // generated on first panel open
|
|
let talkOpen = false;
|
|
|
|
// Wizard state
|
|
let wizardPhase = null; // 'skills' | 'integrations' | 'fields' | 'review'
|
|
let wizardSkillsData = null; // cached from skills/index.json
|
|
let wizardIntegrationsData = null; // cached from integrations/index.json
|
|
let wizardSkillsCache = {}; // { filename: full skill object (with prompt) }
|
|
let wizardIntegrationsCache = {}; // { filename: full integration object (with prompt) }
|
|
let wizardSelectedSkills = new Set();
|
|
let wizardSelectedIntegrations = new Set();
|
|
let wizardFieldValues = {}; // { stepIndex: { fieldKey: value } }
|
|
let wizardIntegrationStep = 0; // current index in fields phase
|
|
let wizardSelectedIntegrationList = [];
|
|
let invoicesData = []; // cached full invoice list for filtering/export
|
|
|
|
/* ─── Debug logger ─────────────────────────────────────────── */
|
|
function dbg(label, data) {
|
|
console.log(`[Derez] ${label}`);
|
|
console.log(data);
|
|
}
|
|
|
|
/* ─── Response normalisers ──────────────────────────────────── */
|
|
// n8n can wrap items as {json:{…}} or double-wrap arrays [[…]]
|
|
function normalizeAgents(raw) {
|
|
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
|
// { data: [...] } envelope — recurse into the array
|
|
if (Array.isArray(raw.data)) return normalizeAgents(raw.data);
|
|
// Plain single agent object — wrap it
|
|
return [raw];
|
|
}
|
|
if (!Array.isArray(raw)) return [];
|
|
// unwrap double-wrapped array: [[{…}]] → [{…}]
|
|
if (raw.length > 0 && Array.isArray(raw[0])) return normalizeAgents(raw[0]);
|
|
// unwrap [{data:[…]}] envelope (agents list with nested data array)
|
|
if (
|
|
raw.length > 0 &&
|
|
raw[0] !== null &&
|
|
typeof raw[0] === "object" &&
|
|
Array.isArray(raw[0].data)
|
|
) {
|
|
return normalizeAgents(raw[0].data);
|
|
}
|
|
// unwrap n8n {json:{…}} envelope
|
|
if (
|
|
raw.length > 0 &&
|
|
raw[0] !== null &&
|
|
typeof raw[0] === "object" &&
|
|
"json" in raw[0]
|
|
) {
|
|
return raw.map((item) => item.json);
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
BOOT
|
|
═══════════════════════════════════════════════════════════════ */
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const sessionId = getCookie("al_session");
|
|
if (sessionId) {
|
|
currentSession = sessionId;
|
|
currentEmail = getCookie("al_email") || "";
|
|
showApp();
|
|
loadAgents();
|
|
} else {
|
|
showAuth();
|
|
}
|
|
|
|
// Close confirm on backdrop click
|
|
document.getElementById("confirm-backdrop").addEventListener("click", (e) => {
|
|
if (e.target === e.currentTarget) confirmClose(false);
|
|
});
|
|
|
|
// Close user panel when clicking outside of it (skip while modal is open)
|
|
document.addEventListener("click", (e) => {
|
|
const wrap = document.getElementById("user-panel-wrap");
|
|
const modal = document.getElementById("account-backdrop");
|
|
if (modal && modal.classList.contains("open")) return;
|
|
if (wrap && !wrap.contains(e.target)) closeUserPanel();
|
|
});
|
|
});
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
AUTH — tab switch
|
|
═══════════════════════════════════════════════════════════════ */
|
|
function switchAuthTab(tab) {
|
|
document
|
|
.querySelectorAll(".auth-switch-btn")
|
|
.forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
|
|
document.getElementById("signin-form").style.display =
|
|
tab === "signin" ? "" : "none";
|
|
document.getElementById("signup-form").style.display =
|
|
tab === "signup" ? "" : "none";
|
|
|
|
// Clear stale error/success messages
|
|
document.getElementById("signin-error").style.display = "none";
|
|
document.getElementById("signup-error").style.display = "none";
|
|
document.getElementById("signup-success").style.display = "none";
|
|
const note = document.getElementById("signin-reset-note");
|
|
if (note) note.style.display = "none";
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
AUTH — sign in
|
|
Sends the email to the agents webhook; a 200 response means
|
|
the account exists and we proceed to the app.
|
|
═══════════════════════════════════════════════════════════════ */
|
|
async function doSignIn() {
|
|
const email = document.getElementById("signin-email").value.trim();
|
|
const password = document.getElementById("signin-password").value;
|
|
hideAuthError("signin-error");
|
|
|
|
if (!isValidEmail(email)) {
|
|
showAuthError("signin-error", "Please enter a valid email address.");
|
|
return;
|
|
}
|
|
if (!password) {
|
|
showAuthError("signin-error", "Please enter your password.");
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById("signin-btn");
|
|
btnLoad(btn, "Signing in\u2026");
|
|
|
|
try {
|
|
dbg("\u2192 Sign In", AUTH_URL);
|
|
const res = await fetch(AUTH_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
if (!res.ok)
|
|
throw new Error(
|
|
"Account not found or incorrect password. Please try again.",
|
|
);
|
|
|
|
const data = await res.json();
|
|
dbg("\u2190 Sign In", data);
|
|
const raw = Array.isArray(data) ? data[0] : data;
|
|
// unwrap n8n {json:{...}} envelope if present
|
|
const item =
|
|
raw && typeof raw === "object" && "json" in raw ? raw.json : raw;
|
|
const sessionId = item?.sessionid;
|
|
if (!sessionId) throw new Error("Authentication failed. Please try again.");
|
|
|
|
currentSession = sessionId;
|
|
currentEmail = email;
|
|
setCookie("al_session", sessionId, 30);
|
|
setCookie("al_email", email, 30);
|
|
showApp();
|
|
loadAgents();
|
|
} catch (err) {
|
|
showAuthError("signin-error", err.message);
|
|
showResetOption();
|
|
} finally {
|
|
btnReset(btn);
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
AUTH — sign up
|
|
═══════════════════════════════════════════════════════════════ */
|
|
async function doSignUp() {
|
|
const email = document.getElementById("signup-email").value.trim();
|
|
const password = document.getElementById("signup-password").value;
|
|
const agent = document.getElementById("signup-agent").value;
|
|
const location = document.getElementById("signup-location").value;
|
|
|
|
hideAuthError("signup-error");
|
|
document.getElementById("signup-success").style.display = "none";
|
|
|
|
if (!isValidEmail(email)) {
|
|
showAuthError("signup-error", "Please enter a valid email address.");
|
|
return;
|
|
}
|
|
if (password.length < 8) {
|
|
showAuthError("signup-error", "Password must be at least 8 characters.");
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById("signup-btn");
|
|
btnLoad(btn, "Hiring agent\u2026");
|
|
|
|
// Open a blank tab NOW while we still have the user-gesture context.
|
|
// Browsers block window.open called after an await, so we must do this
|
|
// synchronously before any async work begins.
|
|
const stripeTab = window.open("", "_blank");
|
|
|
|
try {
|
|
// Step 1: authenticate to get a session
|
|
dbg("\u2192 Hire Agent \u2013 auth", AUTH_URL);
|
|
const authRes = await fetch(AUTH_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
if (!authRes.ok)
|
|
throw new Error("Account creation failed. Please try again later.");
|
|
|
|
const authData = await authRes.json();
|
|
dbg("\u2190 Hire Agent \u2013 auth", authData);
|
|
const authRaw = Array.isArray(authData) ? authData[0] : authData;
|
|
// unwrap n8n {json:{...}} envelope if present
|
|
const authItem =
|
|
authRaw && typeof authRaw === "object" && "json" in authRaw
|
|
? authRaw.json
|
|
: authRaw;
|
|
const sessionId = authItem?.sessionid;
|
|
if (!sessionId)
|
|
throw new Error("Account creation failed. Please try again.");
|
|
|
|
currentSession = sessionId;
|
|
currentEmail = email;
|
|
setCookie("al_session", sessionId, 30);
|
|
setCookie("al_email", email, 30);
|
|
|
|
// Step 2: trigger agent creation
|
|
dbg("\u2192 Hire Agent \u2013 create", SIGNUP_URL);
|
|
const createRes = await fetch(SIGNUP_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password, agent, location }),
|
|
});
|
|
if (!createRes.ok) {
|
|
throw new Error("Agent provisioning failed. Please contact support.");
|
|
}
|
|
|
|
const createData = await createRes.json();
|
|
dbg("\u2190 Hire Agent \u2013 create", createData);
|
|
const createRaw = Array.isArray(createData) ? createData[0] : createData;
|
|
const createItem =
|
|
createRaw && typeof createRaw === "object" && "json" in createRaw
|
|
? createRaw.json
|
|
: createRaw;
|
|
|
|
// Navigate the pre-opened tab to the Stripe checkout URL
|
|
const checkoutUrl =
|
|
createItem?.checkout_url || createItem?.checkoutUrl || createItem?.url;
|
|
if (checkoutUrl && checkoutUrl.startsWith("http")) {
|
|
stripeTab.location.href = checkoutUrl;
|
|
} else {
|
|
stripeTab.close();
|
|
}
|
|
|
|
// Enter the app and load agents now that the agent has been created
|
|
showApp();
|
|
loadAgents();
|
|
} catch (err) {
|
|
stripeTab?.close();
|
|
showAuthError("signup-error", err.message);
|
|
} finally {
|
|
btnReset(btn);
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
AUTH — sign out
|
|
═══════════════════════════════════════════════════════════════ */
|
|
/* ─── Coupon ────────────────────────────────────────────────── */
|
|
function handleCouponKey(e) {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
applyCoupon();
|
|
}
|
|
}
|
|
|
|
async function applyCoupon() {
|
|
const input = document.getElementById("coupon-input");
|
|
const spinner = document.getElementById("coupon-spinner");
|
|
const errorEl = document.getElementById("coupon-error");
|
|
const code = input.value.trim();
|
|
|
|
if (!code) return;
|
|
|
|
input.disabled = true;
|
|
input.classList.remove("coupon-input-error");
|
|
errorEl.style.display = "none";
|
|
spinner.style.display = "";
|
|
|
|
try {
|
|
const res = await apiFetch(COUPON_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
|
|
const text = (await res.text()).trim();
|
|
if (text === "OK") {
|
|
// Valid coupon — hide the box, show success
|
|
couponApplied = true;
|
|
document.getElementById("coupon-box").style.display = "none";
|
|
document.getElementById("coupon-success").style.display = "";
|
|
} else {
|
|
input.classList.add("coupon-input-error");
|
|
errorEl.style.display = "";
|
|
input.disabled = false;
|
|
}
|
|
} catch (err) {
|
|
input.classList.add("coupon-input-error");
|
|
errorEl.textContent = "Could not validate coupon. Try again.";
|
|
errorEl.style.display = "";
|
|
input.disabled = false;
|
|
} finally {
|
|
spinner.style.display = "none";
|
|
}
|
|
}
|
|
|
|
function doSignOut() {
|
|
// Invalidate server session (fire-and-forget)
|
|
if (currentSession) {
|
|
fetch(AUTH_URL, {
|
|
method: "DELETE",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Session-Id": currentSession,
|
|
},
|
|
body: JSON.stringify({ sessionid: currentSession }),
|
|
}).catch(() => {});
|
|
}
|
|
deleteCookie("al_session");
|
|
deleteCookie("al_email");
|
|
localStorage.removeItem("al_email"); // clean up legacy storage
|
|
currentSession = null;
|
|
currentEmail = null;
|
|
agents = [];
|
|
activeUUID = null;
|
|
chatId = null;
|
|
talkOpen = false;
|
|
const tp = document.getElementById("talk-panel");
|
|
if (tp) {
|
|
tp.classList.remove("open");
|
|
}
|
|
const chev = document.getElementById("talk-chevron");
|
|
if (chev) {
|
|
chev.style.transform = "";
|
|
}
|
|
const chatMsgs = document.getElementById("chat-msgs");
|
|
if (chatMsgs) {
|
|
chatMsgs.innerHTML = "";
|
|
}
|
|
|
|
// Reset signup form state so it's clean on next visit
|
|
document.getElementById("signup-btn").style.display = "";
|
|
document.getElementById("signup-success").style.display = "none";
|
|
document.getElementById("signup-email").value = "";
|
|
document.getElementById("signup-password").value = "";
|
|
|
|
// Reset coupon state
|
|
couponApplied = false;
|
|
const couponBox = document.getElementById("coupon-box");
|
|
if (couponBox) couponBox.style.display = "";
|
|
const couponSuccessEl = document.getElementById("coupon-success");
|
|
if (couponSuccessEl) couponSuccessEl.style.display = "none";
|
|
const couponInput = document.getElementById("coupon-input");
|
|
if (couponInput) {
|
|
couponInput.value = "";
|
|
couponInput.classList.remove("coupon-input-error");
|
|
}
|
|
const couponError = document.getElementById("coupon-error");
|
|
if (couponError) couponError.style.display = "none";
|
|
|
|
closeUserPanel();
|
|
window.location.reload();
|
|
}
|
|
|
|
/* ─── View switching ────────────────────────────────────────── */
|
|
function showAuth() {
|
|
document.getElementById("auth-card").style.display = "";
|
|
document.getElementById("app-shell").style.display = "flex";
|
|
document.getElementById("app-shell").classList.add("demo-mode");
|
|
document.body.classList.add("auth-layout");
|
|
populateDemoShell();
|
|
}
|
|
|
|
function showApp() {
|
|
const authCard = document.getElementById("auth-card");
|
|
const shell = document.getElementById("app-shell");
|
|
const fromAuth = document.body.classList.contains("auth-layout");
|
|
|
|
function reveal() {
|
|
authCard.style.display = "none";
|
|
authCard.classList.remove("auth-leaving");
|
|
document.body.classList.remove("auth-layout");
|
|
|
|
// Make shell visible and play entry animation
|
|
shell.style.display = "flex";
|
|
shell.classList.remove("demo-mode");
|
|
shell.classList.add("app-entering");
|
|
document.getElementById("user-email-label").textContent = currentEmail;
|
|
|
|
setTimeout(() => shell.classList.remove("app-entering"), 620);
|
|
}
|
|
|
|
if (fromAuth) {
|
|
// Transitioning from the auth screen: animate auth card out first
|
|
authCard.classList.add("auth-leaving");
|
|
setTimeout(reveal, 310);
|
|
} else {
|
|
// Direct page load while already logged in: skip the auth animation
|
|
reveal();
|
|
}
|
|
}
|
|
|
|
function populateDemoShell() {
|
|
hide("wizard-card");
|
|
// Header
|
|
document.getElementById("user-email-label").textContent = "preview";
|
|
renderComment("My AI Assistant");
|
|
|
|
// Agent tab
|
|
document.getElementById("agent-tabs-bar").innerHTML = `
|
|
<button class="agent-tab active">
|
|
<span class="agent-tab-dot"></span>
|
|
<span>hermes</span>
|
|
<span class="agent-tab-badge">EU</span>
|
|
</button>
|
|
<a class="agent-tab-add" href="https://derez.ai/add.html?email=${encodeURIComponent(currentEmail || "")}" target="_blank" title="Add agent">+ Add Agent</a>`;
|
|
|
|
// Show agent panel with demo data
|
|
hide("no-agents");
|
|
document.getElementById("agent-panel").style.display = "flex";
|
|
|
|
// API Keys
|
|
const keysDot = document.getElementById("keys-status-dot");
|
|
keysDot.className = "contract-dot contract-dot--green";
|
|
keysDot.style.visibility = "visible";
|
|
document.getElementById("keys-body").innerHTML = `
|
|
<div class="key-row">
|
|
<div class="key-info">
|
|
<div class="key-name">Default Key
|
|
<span style="font-size:10px;font-weight:500;padding:1px 7px;border-radius:10px;
|
|
background:#0e2e1c;color:var(--success);margin-left:6px;vertical-align:middle;">Active</span>
|
|
</div>
|
|
<div class="key-stats">
|
|
<span><span class="key-stat-val">350</span>
|
|
<span class="text-muted"> / 500 credits remaining</span></span>
|
|
<span class="sep-dot">·</span>
|
|
<span class="text-muted">Expires Dec 31, 2026</span>
|
|
</div>
|
|
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm">Buy Credits</button>
|
|
</div>`;
|
|
|
|
// Server info
|
|
document.getElementById("server-info-body").innerHTML = `
|
|
<div class="server-info-row">
|
|
<span class="server-info-label">Agent Type</span>
|
|
<span class="server-info-val">Hermes</span>
|
|
</div>
|
|
<div class="server-info-row">
|
|
<span class="server-info-label">Dashboard</span>
|
|
<span class="server-info-val"><a href="#" class="server-info-link" onclick="return false">hermes.derez.ai</a></span>
|
|
</div>
|
|
<div class="server-info-row server-info-row--copy" title="Click to copy">
|
|
<span class="server-info-label">SSH Access</span>
|
|
<span class="server-info-val server-info-copyval">ssh root@hermes.derez.ai -p 2201</span>
|
|
</div>
|
|
<div class="server-info-row">
|
|
<span class="server-info-label">Created</span>
|
|
<span class="server-info-val">2026-01-01</span>
|
|
</div>`;
|
|
|
|
// Contract
|
|
const demoDot = document.getElementById("contract-status-dot");
|
|
demoDot.className = "contract-dot contract-dot--green";
|
|
demoDot.style.visibility = "visible";
|
|
document.getElementById("contract-body").innerHTML = `
|
|
<div class="contract-row">
|
|
<div class="contract-info">
|
|
<span class="contract-type">Junior \u2013 Monthly</span>
|
|
</div>
|
|
</div>`;
|
|
|
|
// Backups
|
|
document.getElementById("backups-body").innerHTML = `
|
|
<div class="backups-list">
|
|
<div class="backup-row">
|
|
<div class="backup-info"><div class="backup-date">2026-05-30 09:12</div></div>
|
|
<button class="btn btn-ghost btn-sm">Restore</button>
|
|
</div>
|
|
<div class="backup-row">
|
|
<div class="backup-info"><div class="backup-date">2026-05-31 14:47</div></div>
|
|
<button class="btn btn-ghost btn-sm">Restore</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
AGENTS — load & render tabs
|
|
═══════════════════════════════════════════════════════════════ */
|
|
async function loadAgents() {
|
|
const bar = document.getElementById("agent-tabs-bar");
|
|
bar.innerHTML =
|
|
'<div class="tabs-loading"><div class="spinner"></div> Loading agents…</div>';
|
|
hide("agent-panel");
|
|
hide("no-agents");
|
|
|
|
try {
|
|
const _agentsUrl2 = `${AGENTS_URL}?email=${encodeURIComponent(currentEmail)}`;
|
|
dbg("→ List Agents", _agentsUrl2);
|
|
const res = await apiFetch(_agentsUrl2);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
let data = [];
|
|
try {
|
|
data = await res.json();
|
|
} catch (_) {}
|
|
dbg("← List Agents", data);
|
|
processAgents(normalizeAgents(data));
|
|
} catch (err) {
|
|
bar.innerHTML = `<span class="tabs-error">${escHtml(err.message)}</span>`;
|
|
}
|
|
}
|
|
|
|
function processAgents(data) {
|
|
agents = data;
|
|
dbg(
|
|
"processAgents agents[0] (JSON)",
|
|
agents.length ? JSON.stringify(agents[0]) : "(empty)",
|
|
);
|
|
renderAgentTabs();
|
|
|
|
if (agents.length > 0) {
|
|
// Accept UUID (spec) or uuid (n8n lowercase) as fallback
|
|
selectAgent(agents[0].UUID || agents[0].uuid || "");
|
|
} else {
|
|
show("no-agents");
|
|
hide("agent-panel");
|
|
}
|
|
}
|
|
|
|
function renderAgentTabs() {
|
|
const bar = document.getElementById("agent-tabs-bar");
|
|
if (!agents.length) {
|
|
bar.innerHTML = '<span class="tabs-empty">No agents on your account</span>';
|
|
return;
|
|
}
|
|
bar.innerHTML =
|
|
agents
|
|
.map((a) => {
|
|
const uuid = a.UUID || a.uuid || "";
|
|
const server = a.server || "";
|
|
return `
|
|
<button class="agent-tab${uuid === activeUUID ? " active" : ""}"
|
|
onclick="selectAgent('${escAttr(uuid)}')">
|
|
<span class="agent-tab-dot"></span>
|
|
<span>${escHtml(uuid)}</span>
|
|
<span class="agent-tab-badge">${escHtml(server.toUpperCase())}</span>
|
|
</button>
|
|
`;
|
|
})
|
|
.join("") +
|
|
`<a class="agent-tab-add" href="https://derez.ai/add.html?email=${encodeURIComponent(currentEmail || "")}" target="_blank" title="Add agent">+ Add Agent</a>`;
|
|
}
|
|
|
|
function selectAgent(uuid) {
|
|
activeUUID = uuid;
|
|
renderAgentTabs();
|
|
|
|
hide("no-agents");
|
|
// Show agent panel using flex so its children lay out correctly
|
|
const panel = document.getElementById("agent-panel");
|
|
panel.style.display = "flex";
|
|
|
|
// Reset password field
|
|
document.getElementById("pw-new-input").value = "";
|
|
togglePwSave("");
|
|
|
|
// Reset comment
|
|
cancelComment();
|
|
renderComment("");
|
|
|
|
hide("wizard-card");
|
|
loadKeys(uuid);
|
|
loadBackups(uuid);
|
|
loadAgentInfo(uuid);
|
|
loadContract(uuid);
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
AGENT INFO
|
|
═══════════════════════════════════════════════════════════════ */
|
|
// Strip the extra surrounding quotes n8n injects into string values: '"Hermes"' → 'Hermes'
|
|
function stripQuotes(v) {
|
|
if (v == null) return "—";
|
|
return String(v).replace(/^"|"$/g, "").trim() || "—";
|
|
}
|
|
|
|
async function loadAgentInfo(uuid) {
|
|
const body = document.getElementById("server-info-body");
|
|
body.innerHTML = '<div class="loading-row"><div class="spinner"></div></div>';
|
|
activeAgentInfo = null;
|
|
hide("wizard-card");
|
|
|
|
try {
|
|
const _infoUrl = `${AGENT_INFO_URL}?uuid=${encodeURIComponent(uuid)}`;
|
|
dbg("→ Agent Info", _infoUrl);
|
|
const res = await apiFetch(_infoUrl);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const json = await res.json();
|
|
dbg("← Agent Info", json);
|
|
|
|
// Unwrap [[{...}]], [{...}], or plain {...}
|
|
let info = json;
|
|
if (Array.isArray(info)) info = info[0]; // outer array
|
|
if (Array.isArray(info)) info = info[0]; // inner array ([[...]])
|
|
if (!info || typeof info !== "object")
|
|
throw new Error("Unexpected agent info format");
|
|
|
|
activeAgentInfo = info;
|
|
renderAgentInfo(info);
|
|
renderComment(info.comment);
|
|
loadWizard(info);
|
|
} catch (err) {
|
|
body.innerHTML = `<p class="inline-error">${escHtml(err.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
/* ─── Header comment ────────────────────────────────────────── */
|
|
function renderComment(text) {
|
|
const el = document.getElementById("header-comment-display");
|
|
if (text && text.trim()) {
|
|
el.textContent = stripQuotes(text);
|
|
el.classList.remove("comment-empty");
|
|
} else {
|
|
el.textContent = "Name your Agent";
|
|
el.classList.add("comment-empty");
|
|
}
|
|
}
|
|
|
|
function editComment() {
|
|
const display = document.getElementById("header-comment-display");
|
|
const edit = document.getElementById("header-comment-edit");
|
|
const input = document.getElementById("header-comment-input");
|
|
const current = display.classList.contains("comment-empty")
|
|
? ""
|
|
: display.textContent;
|
|
input.value = current;
|
|
display.style.display = "none";
|
|
edit.style.display = "flex";
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
|
|
function cancelComment() {
|
|
document.getElementById("header-comment-display").style.display = "";
|
|
document.getElementById("header-comment-edit").style.display = "none";
|
|
}
|
|
|
|
function handleCommentKey(e) {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
saveComment();
|
|
}
|
|
if (e.key === "Escape") cancelComment();
|
|
}
|
|
|
|
async function saveComment() {
|
|
const input = document.getElementById("header-comment-input");
|
|
const btn = document.getElementById("comment-save-btn");
|
|
const text = input.value.trim();
|
|
btnLoad(btn, "Saving…");
|
|
try {
|
|
dbg("→ Save Comment", AGENT_INFO_URL);
|
|
const res = await apiFetch(AGENT_INFO_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ uuid: activeUUID, key: "comment", value: text }),
|
|
});
|
|
if (!res.ok)
|
|
throw new Error(`Failed to save comment (HTTP ${res.status}).`);
|
|
renderComment(text);
|
|
cancelComment();
|
|
toast("Comment saved.", "success");
|
|
} catch (err) {
|
|
toast(err.message, "error");
|
|
} finally {
|
|
btnReset(btn);
|
|
}
|
|
}
|
|
|
|
function renderAgentInfo(info) {
|
|
const body = document.getElementById("server-info-body");
|
|
const domain = stripQuotes(info.domain);
|
|
const domainHref =
|
|
domain !== "—"
|
|
? domain.startsWith("http")
|
|
? domain
|
|
: `https://${domain}`
|
|
: null;
|
|
|
|
const port = stripQuotes(info.ssh_port);
|
|
const sshCmd = `ssh root@${activeUUID}.derez.ai -p ${port}`;
|
|
|
|
const rows = [
|
|
`<div class="server-info-row">
|
|
<span class="server-info-label">Agent Type</span>
|
|
<span class="server-info-val">${escHtml(stripQuotes(info.agent))}</span>
|
|
</div>`,
|
|
`<div class="server-info-row">
|
|
<span class="server-info-label">Dashboard</span>
|
|
<span class="server-info-val">${
|
|
domainHref
|
|
? `<a href="${escAttr(domainHref)}" target="_blank" rel="noopener" class="server-info-link">${escHtml(domain)}</a>`
|
|
: escHtml(domain)
|
|
}</span>
|
|
</div>`,
|
|
`<div class="server-info-row server-info-row--copy" onclick="copySSHAccess()" title="Click to copy">
|
|
<span class="server-info-label">SSH Access</span>
|
|
<span class="server-info-val server-info-copyval">${escHtml(sshCmd)}</span>
|
|
</div>`,
|
|
`<div class="server-info-row">
|
|
<span class="server-info-label">Created</span>
|
|
<span class="server-info-val">${escHtml(stripQuotes(info.created))}</span>
|
|
</div>`,
|
|
];
|
|
body.innerHTML = rows.join("");
|
|
}
|
|
|
|
function copySSHAccess() {
|
|
if (!activeAgentInfo) return;
|
|
const port = stripQuotes(activeAgentInfo.ssh_port);
|
|
const cmd = `ssh root@${activeUUID}.derez.ai -p ${port}`;
|
|
navigator.clipboard
|
|
.writeText(cmd)
|
|
.then(() => toast("SSH command copied to clipboard.", "info"))
|
|
.catch(() => toast("Could not access clipboard.", "error"));
|
|
}
|
|
|
|
function openAgent() {
|
|
if (!activeAgentInfo) {
|
|
toast("Agent info not loaded yet.", "warning");
|
|
return;
|
|
}
|
|
const domain = stripQuotes(activeAgentInfo.domain);
|
|
if (!domain || domain === "\u2014") {
|
|
toast("No dashboard URL available for this agent.", "warning");
|
|
return;
|
|
}
|
|
const href = domain.startsWith("http") ? domain : `https://${domain}`;
|
|
window.open(href, "_blank", "noopener");
|
|
}
|
|
|
|
function openWizard() {
|
|
const card = document.getElementById("wizard-card");
|
|
const btn = document.getElementById("wizard-toggle-btn");
|
|
if (card.style.display !== "none") {
|
|
// Wizard is visible — close it, restore button label
|
|
hide("wizard-card");
|
|
if (btn) btn.textContent = "Integrations";
|
|
return;
|
|
}
|
|
// Wizard is hidden — open from step 1, update button label
|
|
wizardPhase = "skills";
|
|
wizardSelectedSkills = new Set();
|
|
wizardSelectedIntegrations = new Set();
|
|
wizardFieldValues = {};
|
|
wizardIntegrationStep = 0;
|
|
wizardSelectedIntegrationList = [];
|
|
show("wizard-card");
|
|
if (btn) btn.textContent = "Backup";
|
|
renderWizardStep();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
KEYS
|
|
═══════════════════════════════════════════════════════════════ */
|
|
async function loadKeys(uuid) {
|
|
const body = document.getElementById("keys-body");
|
|
body.innerHTML = '<div class="loading-row"><div class="spinner"></div></div>';
|
|
document.getElementById("keys-status-dot").style.visibility = "hidden";
|
|
|
|
try {
|
|
const _keysUrl = `${KEYS_URL}?uuid=${encodeURIComponent(uuid)}`;
|
|
dbg("→ List API Keys", _keysUrl);
|
|
const res = await apiFetch(_keysUrl);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const json = await res.json();
|
|
dbg("← List API Keys", json);
|
|
// Response is [{data: [...]}, ...] or {data: [...]} or flat array
|
|
const first = Array.isArray(json) ? json[0] : json;
|
|
const keys =
|
|
first && Array.isArray(first.data)
|
|
? first.data
|
|
: Array.isArray(json)
|
|
? json
|
|
: [];
|
|
renderKeys(keys);
|
|
} catch (err) {
|
|
body.innerHTML = `<p class="inline-error">${escHtml(err.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
function renderKeys(keys) {
|
|
const body = document.getElementById("keys-body");
|
|
const dot = document.getElementById("keys-status-dot");
|
|
|
|
if (!keys.length) {
|
|
dot.className = "contract-dot contract-dot--red";
|
|
dot.style.visibility = "visible";
|
|
body.innerHTML =
|
|
'<p class="text-muted" style="font-size:13px; padding: 4px 0;">No API keys found.</p>';
|
|
return;
|
|
}
|
|
|
|
const hasActive = keys.some((k) => !k.disabled);
|
|
dot.className = `contract-dot ${hasActive ? "contract-dot--green" : "contract-dot--red"}`;
|
|
dot.style.visibility = "visible";
|
|
|
|
body.innerHTML = keys
|
|
.map((k) => {
|
|
const used = (k.limit || 0) - (k.limit_remaining || 0);
|
|
const total = k.limit || 0;
|
|
const pct =
|
|
total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0;
|
|
const progClass =
|
|
pct < 50 ? "prog-low" : pct < 80 ? "prog-mid" : "prog-high";
|
|
const exp = k.expires_at
|
|
? new Date(k.expires_at).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})
|
|
: "";
|
|
const statusColor = k.disabled ? "var(--danger)" : "var(--success)";
|
|
const statusLabel = k.disabled ? "Disabled" : "Active";
|
|
|
|
return `
|
|
<div class="key-row">
|
|
<div class="key-info">
|
|
<div class="key-name">
|
|
${escHtml(k.name)}
|
|
<span style="font-size:10px; font-weight:500; padding:1px 7px; border-radius:10px;
|
|
background:${k.disabled ? "var(--danger-dim)" : "#0e2e1c"};
|
|
color:${statusColor}; margin-left:6px; vertical-align:middle;">
|
|
${statusLabel}
|
|
</span>
|
|
</div>
|
|
<div class="key-stats">
|
|
<span>
|
|
<span class="key-stat-val">${k.limit_remaining ?? "\u2014"}</span>
|
|
<span class="text-muted"> / ${total} credits</span>
|
|
</span>
|
|
${exp ? `<span class="sep-dot">·</span><span class="text-muted">${escHtml(exp)}</span>` : ""}
|
|
</div>
|
|
|
|
</div>
|
|
<button type="button" class="btn btn-ghost btn-sm"
|
|
style="align-self:center"
|
|
onclick="buyCredits('${escAttr(k.name)}')">
|
|
Buy Credits
|
|
</button>
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function buyCredits(keyName) {
|
|
const backdrop = document.getElementById("credits-backdrop");
|
|
backdrop._keyName = keyName;
|
|
document.getElementById("credits-key-name").textContent = keyName;
|
|
// default to first option
|
|
const radios = backdrop.querySelectorAll('input[name="credits-amount"]');
|
|
radios.forEach((r, i) => (r.checked = i === 0));
|
|
backdrop.classList.add("open");
|
|
}
|
|
|
|
function buyCreditsClose() {
|
|
document.getElementById("credits-backdrop").classList.remove("open");
|
|
}
|
|
|
|
function buyCreditsSubmit() {
|
|
const backdrop = document.getElementById("credits-backdrop");
|
|
const selected = backdrop.querySelector(
|
|
'input[name="credits-amount"]:checked',
|
|
);
|
|
if (!selected) {
|
|
toast("Please select an amount.", "warning");
|
|
return;
|
|
}
|
|
|
|
const keyName = backdrop._keyName;
|
|
const params = new URLSearchParams(window.location.search);
|
|
const activeAgent = agents.find((a) => a.UUID === activeUUID);
|
|
const couponInput = document.getElementById("coupon-input");
|
|
const coupon = couponApplied && couponInput ? couponInput.value.trim() : "";
|
|
|
|
const fields = {
|
|
product: "prod_Ufj2wCf4rhemT8",
|
|
key: keyName || "",
|
|
value: selected.value,
|
|
agent: activeUUID || "",
|
|
period: "",
|
|
location: activeAgent?.server || "",
|
|
email: currentEmail || "",
|
|
coupon,
|
|
utm_source: params.get("utm_source") || "",
|
|
utm_medium: params.get("utm_medium") || "",
|
|
utm_campaign: params.get("utm_campaign") || "",
|
|
utm_term: params.get("utm_term") || "",
|
|
utm_content: params.get("utm_content") || "",
|
|
};
|
|
|
|
const form = document.createElement("form");
|
|
form.method = "POST";
|
|
form.action = CREDITS_URL;
|
|
form.target = "_self";
|
|
|
|
for (const [name, val] of Object.entries(fields)) {
|
|
const inp = document.createElement("input");
|
|
inp.type = "hidden";
|
|
inp.name = name;
|
|
inp.value = val;
|
|
form.appendChild(inp);
|
|
}
|
|
|
|
document.body.appendChild(form);
|
|
form.submit();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
INSTANCE PASSWORD
|
|
═══════════════════════════════════════════════════════════════ */
|
|
function togglePwSave(value) {
|
|
document.getElementById("pw-save-btn").style.display = value
|
|
? "flex"
|
|
: "none";
|
|
}
|
|
|
|
async function savePassword() {
|
|
const newPw = document.getElementById("pw-new-input").value;
|
|
const btn = document.getElementById("pw-save-btn");
|
|
|
|
if (!newPw) {
|
|
toast("Please enter a new password.", "warning");
|
|
return;
|
|
}
|
|
if (newPw.length < 8) {
|
|
toast("Password must be at least 8 characters.", "warning");
|
|
return;
|
|
}
|
|
|
|
// Lock the button immediately so a second click can't corrupt the dialog Promise
|
|
btn.disabled = true;
|
|
|
|
const ok = await confirmDialog(
|
|
"Update Instance Password",
|
|
"This will immediately update the root password on your server. Make sure to save it somewhere safe. Continue?",
|
|
);
|
|
|
|
if (!ok) {
|
|
btn.disabled = false; // user cancelled — restore button
|
|
return;
|
|
}
|
|
|
|
btnLoad(btn, "Saving\u2026");
|
|
|
|
try {
|
|
dbg("\u2192 Update Password", PASSWORD_URL);
|
|
const res = await apiFetch(PASSWORD_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
uuid: activeUUID,
|
|
email: currentEmail,
|
|
password: newPw,
|
|
}),
|
|
});
|
|
if (!res.ok)
|
|
throw new Error(`Failed to update password (HTTP ${res.status}).`);
|
|
|
|
toast("Password updated successfully.", "success");
|
|
document.getElementById("pw-new-input").value = "";
|
|
togglePwSave("");
|
|
} catch (err) {
|
|
toast(err.message, "error");
|
|
} finally {
|
|
btnReset(btn);
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
ACCOUNT / USER PANEL
|
|
═══════════════════════════════════════════════════════════════ */
|
|
function toggleUserPanel() {
|
|
const backdrop = document.getElementById("account-backdrop");
|
|
if (!backdrop) return;
|
|
backdrop.classList.add("open");
|
|
const emailEl = document.getElementById("acct-email-display");
|
|
if (emailEl) emailEl.textContent = currentEmail || "—";
|
|
loadUserData();
|
|
loadInvoices();
|
|
}
|
|
|
|
async function loadUserData() {
|
|
try {
|
|
const res = await apiFetch(
|
|
`${INVOICE_EMAIL_URL}?email=${encodeURIComponent(currentEmail || "")}`,
|
|
);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
let data = await res.json();
|
|
dbg("\u2190 User data", data);
|
|
// unwrap envelope: plain array or {data:[...]}
|
|
if (Array.isArray(data)) {
|
|
const first = data[0];
|
|
data =
|
|
first && typeof first === "object" && "json" in first
|
|
? first.json
|
|
: first;
|
|
} else if (data && !Array.isArray(data) && Array.isArray(data.data)) {
|
|
data = data.data[0];
|
|
}
|
|
if (!data) return;
|
|
|
|
const invoiceEmail = stripQuotes(
|
|
data.invoice_mail || data.invoice_email || data.invoiceEmail || "",
|
|
);
|
|
const invInput = document.getElementById("invoice-email-input");
|
|
if (invInput && invoiceEmail) invInput.value = invoiceEmail;
|
|
|
|
const code = stripQuotes(
|
|
data.affiliate_code ||
|
|
data.affiliateCode ||
|
|
data.referral_code ||
|
|
data.referralCode ||
|
|
"",
|
|
);
|
|
const codeEl = document.getElementById("acct-affiliate-code");
|
|
const copyBtn = document.getElementById("acct-affiliate-copy-btn");
|
|
if (codeEl) codeEl.textContent = code || "—";
|
|
if (copyBtn) copyBtn.style.display = code ? "inline-flex" : "none";
|
|
} catch (err) {
|
|
dbg("\u2190 User data error", err.message);
|
|
}
|
|
}
|
|
|
|
function copyAffiliateCode() {
|
|
const code = document.getElementById("acct-affiliate-code")?.textContent;
|
|
if (!code || code === "—") return;
|
|
const link = `https://derez.ai?utm_source=${encodeURIComponent(code)}`;
|
|
navigator.clipboard
|
|
.writeText(link)
|
|
.then(() => toast("Affiliate link copied!", "success"));
|
|
}
|
|
|
|
function closeUserPanel() {
|
|
document.getElementById("account-backdrop").classList.remove("open");
|
|
const inp = document.getElementById("acct-pw-input");
|
|
if (inp) inp.value = "";
|
|
const btn = document.getElementById("acct-pw-save-btn");
|
|
if (btn) btn.style.display = "none";
|
|
const invBtn = document.getElementById("invoice-email-save-btn");
|
|
if (invBtn) invBtn.style.display = "none";
|
|
const codeEl = document.getElementById("acct-affiliate-code");
|
|
if (codeEl) codeEl.textContent = "—";
|
|
const copyBtn = document.getElementById("acct-affiliate-copy-btn");
|
|
if (copyBtn) copyBtn.style.display = "none";
|
|
}
|
|
|
|
function toggleAccountPwSave(value) {
|
|
const btn = document.getElementById("acct-pw-save-btn");
|
|
if (btn) btn.style.display = value ? "flex" : "none";
|
|
}
|
|
|
|
async function saveAccountPassword() {
|
|
const newPw = document.getElementById("acct-pw-input").value;
|
|
const btn = document.getElementById("acct-pw-save-btn");
|
|
|
|
if (!newPw) {
|
|
toast("Please enter a new password.", "warning");
|
|
return;
|
|
}
|
|
if (newPw.length < 8) {
|
|
toast("Password must be at least 8 characters.", "warning");
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
const ok = await confirmDialog(
|
|
"Change Account Password",
|
|
"This will update your account login password immediately. Continue?",
|
|
);
|
|
if (!ok) {
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
|
|
btnLoad(btn, "");
|
|
try {
|
|
dbg("→ Update Account Password", ACCOUNT_PASSWORD_URL);
|
|
const res = await apiFetch(ACCOUNT_PASSWORD_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: currentEmail, password: newPw }),
|
|
});
|
|
if (!res.ok)
|
|
throw new Error(
|
|
`Failed to update account password (HTTP ${res.status}).`,
|
|
);
|
|
|
|
toast("Account password updated.", "success");
|
|
closeUserPanel();
|
|
} catch (err) {
|
|
toast(err.message, "error");
|
|
} finally {
|
|
btnReset(btn);
|
|
}
|
|
}
|
|
|
|
function toggleInvoiceEmailSave(value) {
|
|
const btn = document.getElementById("invoice-email-save-btn");
|
|
if (btn) btn.style.display = value ? "flex" : "none";
|
|
}
|
|
|
|
async function saveInvoiceEmail() {
|
|
const input = document.getElementById("invoice-email-input");
|
|
const btn = document.getElementById("invoice-email-save-btn");
|
|
const email = input.value.trim();
|
|
|
|
if (!email || !isValidEmail(email)) {
|
|
toast("Please enter a valid email address.", "warning");
|
|
return;
|
|
}
|
|
|
|
btnLoad(btn, "");
|
|
try {
|
|
const res = await apiFetch(INVOICE_EMAIL_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: currentEmail, invoice_email: email }),
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
localStorage.setItem("al_invoice_email", email);
|
|
toast("Invoice email saved.", "success");
|
|
btn.style.display = "none";
|
|
} catch (err) {
|
|
toast(err.message, "error");
|
|
} finally {
|
|
btnReset(btn);
|
|
}
|
|
}
|
|
|
|
async function loadInvoices() {
|
|
const body = document.getElementById("invoices-body");
|
|
if (!body) return;
|
|
body.innerHTML = '<div class="loading-row"><div class="spinner"></div></div>';
|
|
try {
|
|
const res = await apiFetch(
|
|
`${INVOICES_URL}?email=${encodeURIComponent(currentEmail || "")}`,
|
|
);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
let data = await res.json();
|
|
dbg("\u2190 Invoices", data);
|
|
// unwrap envelope: {data:[...]} or [{json:{...}}] or plain array
|
|
if (data && !Array.isArray(data) && Array.isArray(data.data)) {
|
|
data = data.data;
|
|
} else if (Array.isArray(data)) {
|
|
data = data.map((item) =>
|
|
item && typeof item === "object" && "json" in item ? item.json : item,
|
|
);
|
|
} else {
|
|
data = [];
|
|
}
|
|
invoicesData = data;
|
|
applyInvoiceFilter();
|
|
const filterInput = document.getElementById("invoice-filter");
|
|
if (filterInput) filterInput.value = "";
|
|
} catch (err) {
|
|
dbg("\u2190 Invoices error", err.message);
|
|
body.innerHTML =
|
|
'<p class="text-muted" style="font-size:13px;padding:4px 0;">Could not load invoices.</p>';
|
|
}
|
|
}
|
|
|
|
function applyInvoiceFilter() {
|
|
const q = (
|
|
document.getElementById("invoice-filter").value || ""
|
|
).toLowerCase();
|
|
const filtered = q
|
|
? invoicesData.filter(
|
|
(inv) =>
|
|
(inv.date || "").toLowerCase().includes(q) ||
|
|
(inv.uuid || "").toLowerCase().includes(q) ||
|
|
(inv.product || "").toLowerCase().includes(q) ||
|
|
(inv.price || "").toLowerCase().includes(q),
|
|
)
|
|
: invoicesData;
|
|
renderInvoices(filtered);
|
|
}
|
|
|
|
function renderInvoices(data) {
|
|
const body = document.getElementById("invoices-body");
|
|
if (!data.length) {
|
|
body.innerHTML =
|
|
'<p class="text-muted" style="font-size:13px;padding:4px 0;">No invoices yet.</p>';
|
|
return;
|
|
}
|
|
body.innerHTML = `
|
|
<div class="invoice-list">
|
|
<div class="invoice-row invoice-row--head">
|
|
<span>Date</span>
|
|
<span>Agent</span>
|
|
<span>Product</span>
|
|
<span>Price</span>
|
|
<span></span>
|
|
</div>
|
|
${data
|
|
.map(
|
|
(inv) => `
|
|
<div class="invoice-row">
|
|
<span class="invoice-date">${escHtml(inv.date || "—")}</span>
|
|
<span class="invoice-uuid">${escHtml(inv.uuid || "—")}</span>
|
|
<span class="invoice-product">${escHtml(inv.product || "—")}</span>
|
|
<span class="invoice-price">€ ${escHtml(String(inv.price ?? "—"))}</span>
|
|
<span class="invoice-dl">${inv.invoice_link ? `<a href="${escAttr(inv.invoice_link)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm" style="font-size:11px;padding:3px 8px;">Download</a>` : ""}</span>
|
|
</div>`,
|
|
)
|
|
.join("")}
|
|
</div>`;
|
|
}
|
|
|
|
function exportInvoicesCSV() {
|
|
const q = (
|
|
document.getElementById("invoice-filter").value || ""
|
|
).toLowerCase();
|
|
const filtered = q
|
|
? invoicesData.filter(
|
|
(inv) =>
|
|
(inv.date || "").toLowerCase().includes(q) ||
|
|
(inv.uuid || "").toLowerCase().includes(q) ||
|
|
(inv.product || "").toLowerCase().includes(q) ||
|
|
(inv.price || "").toLowerCase().includes(q),
|
|
)
|
|
: invoicesData;
|
|
if (!filtered.length) {
|
|
toast("No invoices to export.", "warning");
|
|
return;
|
|
}
|
|
const rows = ["Date,Agent,Product,Price,Invoice Link"];
|
|
for (const inv of filtered) {
|
|
rows.push(
|
|
[
|
|
inv.date || "",
|
|
inv.uuid || "",
|
|
inv.product || "",
|
|
inv.price || "",
|
|
inv.invoice_link || "",
|
|
].join(","),
|
|
);
|
|
}
|
|
const blob = new Blob([rows.join("\n")], { type: "text/csv" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "invoices.csv";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
/* ═══════════════════════════════════════════════════════════════
|
|
HELPERS
|
|
═══════════════════════════════════════════════════════════════ */
|
|
function escHtml(s) {
|
|
if (typeof s !== "string" && typeof s !== "number") return "";
|
|
return String(s)
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
function escAttr(s) {
|
|
if (typeof s !== "string" && typeof s !== "number") return "";
|
|
return String(s)
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function isValidEmail(e) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
|
}
|
|
|
|
function formatBackupDate(s) {
|
|
if (!s) return "—";
|
|
const d = new Date(s);
|
|
if (isNaN(d.getTime())) return s;
|
|
return d.toLocaleString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
function formatDate(s) {
|
|
if (!s) return "—";
|
|
const d = new Date(s);
|
|
if (isNaN(d.getTime())) return s;
|
|
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
const day = String(d.getDate()).padStart(2, "0");
|
|
const year = d.getFullYear();
|
|
const hour = String(d.getHours()).padStart(2, "0");
|
|
const minute = String(d.getMinutes()).padStart(2, "0");
|
|
return `${year}-${month}-${day} ${hour}:${minute}`;
|
|
}
|
|
|
|
function show(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.display = "";
|
|
}
|
|
|
|
function hide(id) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.display = "none";
|
|
}
|
|
|
|
function btnLoad(btn, label) {
|
|
if (!btn) return;
|
|
btn._origHTML = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML =
|
|
label +
|
|
' <span class="spinner-sm" style="display:inline-block;vertical-align:middle;margin-left:4px"></span>';
|
|
}
|
|
|
|
function btnReset(btn) {
|
|
if (!btn) return;
|
|
btn.disabled = false;
|
|
btn.innerHTML = btn._origHTML || btn.innerHTML;
|
|
}
|
|
|
|
function showAuthError(id, msg) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.innerHTML = `<span>${escHtml(msg)}</span>`;
|
|
el.style.display = "flex";
|
|
}
|
|
|
|
function hideAuthError(id) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.style.display = "none";
|
|
}
|
|
|
|
function showResetOption() {
|
|
const note = document.getElementById("signin-reset-note");
|
|
if (note) note.style.display = "";
|
|
}
|
|
|
|
async function sendPasswordReset() {
|
|
const email = document.getElementById("signin-email").value.trim();
|
|
if (!isValidEmail(email)) {
|
|
toast("Please enter a valid email address.", "warning");
|
|
return;
|
|
}
|
|
const btn = document.getElementById("signin-reset-btn");
|
|
if (btn) btn.disabled = true;
|
|
try {
|
|
await fetch(PW_RESET_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
// Always show success — never reveal whether address exists
|
|
const note = document.getElementById("signin-reset-note");
|
|
if (note) note.style.display = "";
|
|
} catch (_) {
|
|
// silently ignore
|
|
} finally {
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function setCookie(name, value, days) {
|
|
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
|
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
|
|
}
|
|
|
|
function getCookie(name) {
|
|
const k = `${name}=`;
|
|
const parts = document.cookie.split("; ");
|
|
for (const p of parts) {
|
|
if (p.startsWith(k)) return decodeURIComponent(p.slice(k.length));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function deleteCookie(name) {
|
|
document.cookie = `${name}=; max-age=0; path=/`;
|
|
}
|
|
|
|
async function apiFetch(url, opts) {
|
|
const headers = { ...(opts?.headers || {}) };
|
|
if (currentSession) headers["X-Session-Id"] = currentSession;
|
|
return fetch(url, { ...opts, headers });
|
|
}
|