Compare commits

..

65 Commits

Author SHA1 Message Date
oliver 3649cedabe Update app.js 2026-06-22 18:46:40 -03:00
oliver 15dda4632f Update app.js 2026-06-22 18:38:27 -03:00
oliver 9ddfc00aa0 Update styles.css 2026-06-22 18:30:39 -03:00
oliver 901f43e497 Update app.js 2026-06-22 18:29:48 -03:00
oliver 09663e3dc8 Update app.js 2026-06-22 18:25:22 -03:00
oliver b6f1d7e15d l 2026-06-22 18:22:35 -03:00
oliver 07fc9f9050 k 2026-06-22 18:17:22 -03:00
oliver 365c69871e 🤝 2026-06-22 17:05:28 -03:00
oliver ad8e17e163 git integration 2026-06-22 17:02:31 -03:00
oliver 361ee5b49d update 2026-06-18 14:22:48 -03:00
oliver b692134c58 n8n skill 2026-06-18 06:05:31 -03:00
oliver f34ab02477 no follow no index 2026-06-15 09:12:06 -03:00
oliver d61c746818 Update app.js 2026-06-13 06:06:50 -03:00
oliver 5309db7e12 Update app.js 2026-06-13 06:06:03 -03:00
oliver 19b2edccf1 Update app.js 2026-06-12 15:02:13 -03:00
oliver a270f60311 general login 2026-06-12 11:06:15 -03:00
oliver f9c8f8fd8c Create favicon.ico 2026-06-11 12:34:36 -03:00
oliver 4042287957 c 2026-06-11 05:26:22 -03:00
oliver ad5d33e8e1 Update index.html 2026-06-11 05:24:49 -03:00
oliver 6dcfd6f17a Update app.js 2026-06-10 20:43:50 -03:00
oliver 592505ac21 Update app.js 2026-06-10 20:41:09 -03:00
oliver ea38cf998a Update app.js 2026-06-10 20:36:27 -03:00
oliver 701f4e8e3e disable 2026-06-10 20:34:06 -03:00
oliver 317f900d52 Update app.js 2026-06-10 19:59:07 -03:00
oliver 24c4421ba7 sryle 2026-06-10 19:56:21 -03:00
oliver 8c0c5a9df7 style 2026-06-10 19:52:56 -03:00
oliver 0f7cdbc432 noice 2026-06-10 19:45:07 -03:00
oliver ce1980c310 expires 2026-06-10 19:39:29 -03:00
oliver 65fdea1e5f webhook 2026-06-10 18:23:23 -03:00
oliver 076763db63 Update index.html 2026-06-10 18:19:01 -03:00
oliver 45d24c394f portal link 2026-06-10 18:14:34 -03:00
oliver dac70c812e Update app.js 2026-06-10 18:04:26 -03:00
oliver 626a244d0c invoice link 2026-06-10 17:56:26 -03:00
oliver bb830a847f v 2026-06-10 17:52:57 -03:00
oliver df7b879b1a Update styles.css 2026-06-10 17:50:00 -03:00
oliver d6e42d9b7d reset mail comment 2026-06-10 17:48:57 -03:00
oliver cf2702c70a Update styles.css 2026-06-10 15:26:04 -03:00
oliver b7381949c6 filter, export 2026-06-10 15:24:50 -03:00
oliver 5c881d0c93 Update app.js 2026-06-09 18:19:40 -03:00
oliver 5b59f6c1a8 . 2026-06-09 18:16:29 -03:00
oliver f26ecc8646 promo 2026-06-09 17:35:45 -03:00
oliver 084a4003e8 Update styles.css 2026-06-09 16:47:17 -03:00
oliver 58c564d263 Update app.js 2026-06-09 14:59:02 -03:00
oliver 3d3986ae2f Update app.js 2026-06-09 14:55:13 -03:00
oliver 5dacbfd1b0 chip 2026-06-09 14:53:56 -03:00
oliver 024c7637a5 c 2026-06-09 14:53:29 -03:00
oliver 2d5e8e12e9 f 2026-06-09 14:52:20 -03:00
oliver 681e175be9 fix 2026-06-09 14:47:21 -03:00
oliver e48cc6e942 o 2026-06-09 14:38:03 -03:00
oliver fc90204d97 Update app.js 2026-06-09 14:09:55 -03:00
oliver fbd620074b set invoice mail 2026-06-09 14:09:45 -03:00
oliver b86a3293da Update app.js 2026-06-09 13:58:01 -03:00
oliver 59871432b9 invoices 2026-06-09 13:51:41 -03:00
oliver d0c5c1285b select 2026-06-09 09:05:42 -03:00
oliver eff9a16f18 Update app.js 2026-06-09 08:51:09 -03:00
oliver 84439e5eb0 Update app.js 2026-06-09 08:29:42 -03:00
oliver 2072ce56a8 Update app.js 2026-06-09 08:25:59 -03:00
oliver b989d79cb9 Update app.js 2026-06-09 08:17:09 -03:00
oliver 8bbb53a8a4 Update app.js 2026-06-08 18:35:58 -03:00
oliver ff457062e6 fix 2026-06-08 17:36:32 -03:00
oliver 33a1165750 new api 2026-06-08 17:31:11 -03:00
oliver 2a8c4c4515 added email 2026-06-08 10:31:31 -03:00
oliver 88d4a2d87f password 2026-06-08 10:17:16 -03:00
oliver 3ab1498e0d name change 2026-06-08 10:08:31 -03:00
oliver 8c668d4cd0 uopdate 2026-06-08 10:01:15 -03:00
13 changed files with 1922 additions and 409 deletions
+44 -12
View File
@@ -17,7 +17,7 @@ Single-page user portal for **derez.ai** — lets customers sign up, log in, and
| Logic | Vanilla JS · `app.js` (edit directly, no bundler) |
| Dev server | `./start` (runs `live-server` on port 8080) |
| Auth | Email stored in `localStorage` key `al_email` |
| Backend | n8n webhooks on `n8n.agent-loft.com` |
| Backend | n8n webhooks on `n8n.derez.ai` |
### File map
@@ -25,6 +25,7 @@ Single-page user portal for **derez.ai** — lets customers sign up, log in, and
index.html ← all markup — auth card + app shell + confirm dialog
styles.css ← all styles — design tokens, layout, components
app.js ← all logic — auth, agents, keys, password, restart, backups, contract
password.html ← standalone page — receives ?token=XXX, sets new password, redirects to app
start ← dev server launcher (live-server)
AGENTS.md ← this file
style_guide.md ← visual design spec (tokens, typography, components)
@@ -79,17 +80,28 @@ body (display:flex; align-items:center; justify-content:center; overflow:hidden
Page load
└── localStorage has 'al_email'?
Yes → showApp() + loadAgents() ← shell made visible immediately, no auth animation
No → showAuth() ← auth card + demo shell shown side-by-side
No → showAuth() ← auth card shown (demo shell hidden)
Sign In
└── GET AGENTS_URL?email=…&password=…
200 → store email → showApp() (animates auth card out) → processAgents()
!200 → show inline error
└── POST AUTH_URL {email, password}
200 + sessionid → store session → showApp() (animates auth card out) → loadAgents()
!200 → show inline error + reveal #reset-pw-wrap (forgot password section)
Password Reset — step 1 (auth screen)
└── User clicks “Send reset email” in #reset-pw-wrap
POST PW_RESET_URL {email}
always → hide signin-error → show #reset-pw-sent confirmation
(response not checked — never reveal whether address exists)
Password Reset — step 2 (password.html?token=XXX)
└── token missing or < 8 chars → show invalid-state
token valid → show password form
POST PW_RESET_URL {token, password}
200 → show success state → progress bar → redirect to https://app.derez.ai
!200 → show error banner, re-enable Save button
Sign Up
└── POST SIGNUP_URL {email, password, agent, location}
200 → open Stripe checkout tab + auto-login immediately
!200 → show inline error
└── “Hire Agent” button links to https://derez.ai/#pricing (no inline form)
Sign Out
└── remove 'al_email' from localStorage → showAuth()
@@ -314,13 +326,32 @@ When status is red, an **Extend Contract →** link is shown pointing to `CONTRA
### 9 · Wizard Complete
```
POST https://n8n.agent-loft.com/webhook/e01d06a3-14c3-4e4e-830f-7d4be9a5f529 (same as Agent Info)
POST https://n8n.derez.ai/webhook/e01d06a3-14c3-4e4e-830f-7d4be9a5f529 (same as Agent Info)
Body: { uuid, key: "WIZZARD", value: "false" }
```
Called when the user clicks **Copy & Finish** in the wizard. Reuses `AGENT_INFO_URL`. The same endpoint accepts all agent config writes via `{ uuid, key, value }` — e.g. comments use `key: "comment"`. No separate constant needed.
The agent info GET response (webhook 6) drives wizard visibility: if the response contains `WIZZARD` with any value other than `false` (or the key is absent), the wizard is shown above the Backups card.
### 10 · Password Reset
```
Step 1 — request reset email
POST https://n8n.derez.ai/webhook/c2ce0eba-eb26-405d-8a90-8d982ec30698
Body: { email }
Step 2 — save new password
POST https://n8n.derez.ai/webhook/c2ce0eba-eb26-405d-8a90-8d982ec30698/update
Body: { token, password, email }
```
**Step 1** is triggered from `index.html` when the user clicks **Send reset email** inside `#reset-pw-wrap` (visible only after a failed sign-in). The response is intentionally ignored and the sent-confirmation is always shown — this avoids revealing whether the email address exists.
**Step 2** is handled entirely by `password.html`, which:
- Reads `?token=XXX` from the URL on load; shows an invalid-state if absent.
- Requires both password fields to match and be ≥ 8 characters before enabling Save.
- On 200 response shows a success state with a 2 s animated progress bar then redirects to `https://app.derez.ai`.
- On !200 shows an inline error banner and re-enables the Save button.
---
## State Variables (`app.js`)
@@ -349,9 +380,10 @@ There is no global keys, backups, contract, or wizard-JSON state that is re-fetc
| Function | What it does |
|---|---|
| `showAuth()` / `showApp()` | toggle between auth card and app shell |
| `switchAuthTab(tab)` | toggle Sign In / Sign Up forms |
| `doSignIn()` | validate email → fetch agents → enter app |
| `doSignUp()` | validate form → POST signup webhook → show Stripe notice |
| `switchAuthTab(tab)` | toggle Sign In / Hire Agent; clears error + hides reset section |
| `doSignIn()` | validate → POST auth webhook → enter app; on failure shows error + calls `showResetOption()` |
| `showResetOption()` | reveals `#reset-pw-wrap` in idle state below the sign-in error |
| `sendPasswordReset()` | POST `PW_RESET_URL` with email → hides error panel → shows sent confirmation |
| `doSignOut()` | clear localStorage → reset state → showAuth |
| `loadAgents()` | fetch agent list, call `processAgents()` |
| `selectAgent(uuid)` | switch active tab, load keys + backups + agent info + contract |
+576 -129
View File
@@ -2,40 +2,31 @@
Derez — User Portal
════════════════════════════════════════════════════════════════ */
/* ─── Webhook URLs ──────────────────────────────────────────── */
const SIGNUP_URL =
"https://n8n.derez.ai/webhook/4de196b7-2ad2-4f9a-9b4d-dc8d3b30865b";
const AGENTS_URL =
"https://n8n.derez.ai/webhook/73b31740-d2c7-46d7-ab71-7a3fef5f77ff";
const KEYS_URL =
"https://n8n.derez.ai/webhook/1f1a6a11-727b-4965-a59a-fde77806d27f";
const PASSWORD_URL =
"https://n8n.derez.ai/webhook/51098cf4-ecfd-4db4-8977-db04f01ce2b1";
const RESTART_URL =
"https://n8n.derez.ai/webhook/dac205df-66e0-4728-90e5-d784cde167af";
const BACKUP_LIST_URL =
"https://n8n.derez.ai/webhook/30eaa32f-378a-4963-9d80-533229d25766";
const BACKUP_URL =
"https://n8n.derez.ai/webhook/30eaa32f-378a-4963-9d80-533229d25766";
const AGENT_INFO_URL =
"https://n8n.derez.ai/webhook/e01d06a3-14c3-4e4e-830f-7d4be9a5f529";
const CHAT_URL =
"https://n8n.derez.ai/webhook/a58d00c4-f0c9-40cd-bb50-4f45f0442ef0";
const REFERRAL_URL =
"https://n8n.derez.ai/webhook/5bb4169d-284a-4006-8952-fcc325da2d22";
const CONTRACT_URL =
"https://n8n.derez.ai/webhook/18591766-147e-4bcb-b9ac-b0f9a92e74bf";
/* ─── 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 PORTAL_URL = `${API_BASE}/account/portal`; // GET ?email= → { url }
const AFFILIATE_LINKS_URL = `${API_BASE}/affiliate/link`; // GET → [{areas:[{area,text,link}]}]
const CONTRACT_EXTEND_URL = "https://derez.ai"; // ← replace with Stripe payment link
const CONTRACT_CANCEL_URL = "https://derez.ai"; // ← replace with Stripe cancellation link
const COUPON_URL =
"https://n8n.derez.ai/webhook/fd6375cb-6d73-4482-bfa3-ef8365f43a67";
// WIZZARD=false is written via AGENT_INFO_URL (POST { uuid, key, value })
const AUTH_URL =
"https://n8n.derez.ai/webhook/e256310a-6627-45ba-a221-599751943fe6";
const ACCOUNT_PASSWORD_URL =
"https://n8n.derez.ai/webhook/4644f196-a31c-4d1a-b76e-03e9afe39302";
const PW_RESET_URL =
"https://n8n.derez.ai/webhook/c2ce0eba-eb26-405d-8a90-8d982ec30698";
/* ─── State ─────────────────────────────────────────────────── */
let currentEmail = null;
@@ -57,7 +48,8 @@ let wizardSelectedSkills = new Set();
let wizardSelectedIntegrations = new Set();
let wizardFieldValues = {}; // { stepIndex: { fieldKey: value } }
let wizardIntegrationStep = 0; // current index in fields phase
let wizardSelectedIntegrationList = []; // ordered list of selected integration objects
let wizardSelectedIntegrationList = [];
let invoicesData = []; // cached full invoice list for filtering/export
/* ─── Debug logger ─────────────────────────────────────────── */
function dbg(label, data) {
@@ -77,6 +69,15 @@ function normalizeAgents(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 &&
@@ -108,9 +109,11 @@ document.addEventListener("DOMContentLoaded", () => {
if (e.target === e.currentTarget) confirmClose(false);
});
// Close user panel when clicking outside of it
// 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();
});
});
@@ -395,7 +398,7 @@ function doSignOut() {
if (couponError) couponError.style.display = "none";
closeUserPanel();
showAuth();
window.location.reload();
}
/* ─── View switching ────────────────────────────────────────── */
@@ -442,19 +445,28 @@ function populateDemoShell() {
document.getElementById("user-email-label").textContent = "preview";
renderComment("My AI Assistant");
// Agent tab
// Agent tab (first agent — hermes — has BACKUP & SSH, second agent — bell — does not)
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" target="_blank" title="Add agent">+</a>`;
<button class="agent-tab">
<span class="agent-tab-dot agent-tab-dot--red"></span>
<span>bell</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";
// Backups — hide the + button to demo a non-BACKUP agent
const makeBtn = document.getElementById("make-backup-btn");
if (makeBtn) makeBtn.style.display = "none";
// API Keys
const keysDot = document.getElementById("keys-status-dot");
keysDot.className = "contract-dot contract-dot--green";
@@ -487,9 +499,9 @@ function populateDemoShell() {
<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">
<div class="server-info-row">
<span class="server-info-label">SSH Access</span>
<span class="server-info-val server-info-copyval">ssh root@hermes.derez.ai -p 2201</span>
<span class="server-info-val">Upgrade</span>
</div>
<div class="server-info-row">
<span class="server-info-label">Created</span>
@@ -498,15 +510,15 @@ function populateDemoShell() {
// Contract
const demoDot = document.getElementById("contract-status-dot");
demoDot.className = "contract-dot contract-dot--green";
demoDot.className = "contract-dot contract-dot--red";
demoDot.style.visibility = "visible";
document.getElementById("contract-body").innerHTML = `
<div class="contract-row">
<div class="contract-info">
<span class="contract-type">Auto - Monthly</span>
<span class="contract-expires">No expiration</span>
<span class="contract-type">Trainee \u2013 Monthly</span>
<span class="contract-expires">expires 2026-07-10</span>
</div>
<a href="#" class="btn btn-ghost btn-sm" style="margin-left:auto;flex-shrink:0;" onclick="return false">Cancel</a>
<a class="contract-extend-link" href="#" onclick="event.preventDefault();openBillingPortal()">Resubscribe &rarr;</a>
</div>`;
// Backups
@@ -577,17 +589,22 @@ function renderAgentTabs() {
.map((a) => {
const uuid = a.UUID || a.uuid || "";
const server = a.server || "";
const expires = a.expires || "";
const hasExpiry = expires && expires !== "null" && expires !== "";
const dotClass = hasExpiry
? "agent-tab-dot agent-tab-dot--red"
: "agent-tab-dot";
return `
<button class="agent-tab${uuid === activeUUID ? " active" : ""}"
onclick="selectAgent('${escAttr(uuid)}')">
<span class="agent-tab-dot"></span>
<span class="${dotClass}"></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" target="_blank" title="Add agent">+</a>`;
`<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) {
@@ -609,8 +626,7 @@ function selectAgent(uuid) {
hide("wizard-card");
loadKeys(uuid);
loadBackups(uuid);
loadAgentInfo(uuid);
loadAgentInfo(uuid).then(() => loadBackups(uuid));
loadContract(uuid);
}
@@ -729,6 +745,10 @@ function renderAgentInfo(info) {
const port = stripQuotes(info.ssh_port);
const sshCmd = `ssh root@${activeUUID}.derez.ai -p ${port}`;
// Check SSH flag from agent info response
const sshRaw = stripQuotes(info.ssh);
const hasSSH = sshRaw !== "—" && sshRaw !== "0" && sshRaw !== "false";
const rows = [
`<div class="server-info-row">
<span class="server-info-label">Agent Type</span>
@@ -742,9 +762,14 @@ function renderAgentInfo(info) {
: escHtml(domain)
}</span>
</div>`,
`<div class="server-info-row server-info-row--copy" onclick="copySSHAccess()" title="Click to copy">
hasSSH
? `<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">SSH Access</span>
<span class="server-info-val">Upgrade</span>
</div>`,
`<div class="server-info-row">
<span class="server-info-label">Created</span>
@@ -858,7 +883,7 @@ function renderKeys(keys) {
day: "numeric",
year: "numeric",
})
: "\u2014";
: "";
const statusColor = k.disabled ? "var(--danger)" : "var(--success)";
const statusLabel = k.disabled ? "Disabled" : "Active";
@@ -876,14 +901,13 @@ function renderKeys(keys) {
<div class="key-stats">
<span>
<span class="key-stat-val">${k.limit_remaining ?? "\u2014"}</span>
<span class="text-muted"> / ${total} credits remaining</span>
<span class="text-muted"> / ${total} credits</span>
</span>
<span class="sep-dot">&middot;</span>
<span class="text-muted">Expires ${exp}</span>
${exp ? `<span class="sep-dot">&middot;</span><span class="text-muted">${escHtml(exp)}</span>` : ""}
</div>
</div>
<button class="btn btn-ghost btn-sm"
<button type="button" class="btn btn-ghost btn-sm"
style="align-self:center"
onclick="buyCredits('${escAttr(k.name)}')">
Buy Credits
@@ -894,8 +918,66 @@ function renderKeys(keys) {
}
function buyCredits(keyName) {
toast(`Opening Derez to purchase credits\u2026`, "info");
window.open("https://derez.ai", "_blank");
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();
}
/* ═══════════════════════════════════════════════════════════════
@@ -924,8 +1006,8 @@ async function savePassword() {
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?",
"Your Hermes & Root Password",
"This will immediately update the root password on your server (the dashboard user is <b>root</b>). Make sure to save it somewhere safe.\n\nNote: You will need to restart the server for the dashboard password to take effect. Continue?",
);
if (!ok) {
@@ -963,25 +1045,89 @@ async function savePassword() {
ACCOUNT / USER PANEL
═══════════════════════════════════════════════════════════════ */
function toggleUserPanel() {
const panel = document.getElementById("user-panel");
if (!panel) return;
if (panel.style.display === "none" || panel.style.display === "") {
document.getElementById("user-panel-email-val").textContent =
currentEmail || "—";
panel.style.display = "block";
} else {
closeUserPanel();
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() {
const panel = document.getElementById("user-panel");
if (!panel) return;
panel.style.display = "none";
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";
// reset tab to settings
const settingsTab = document.getElementById("acct-tab-settings");
const affiliateTab = document.getElementById("acct-tab-affiliate");
const settingsPane = document.getElementById("acct-pane-settings");
const affiliatePane = document.getElementById("acct-pane-affiliate");
if (settingsTab && affiliateTab && settingsPane && affiliatePane) {
affiliateTab.classList.remove("active");
settingsTab.classList.add("active");
affiliatePane.style.display = "none";
settingsPane.style.display = "flex";
}
}
function toggleAccountPwSave(value) {
@@ -1034,6 +1180,305 @@ async function saveAccountPassword() {
}
}
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 openBillingPortal() {
const btn = document.getElementById("portal-btn");
if (!currentEmail) {
toast("No email found. Please sign in again.", "warning");
return;
}
if (btn) btnLoad(btn, "");
try {
const res = await apiFetch(
`${PORTAL_URL}?email=${encodeURIComponent(currentEmail)}`,
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
dbg("\u2190 Portal", data);
const portalUrl =
data.URL || data.url || (data.data && data.data.url) || data;
if (
portalUrl &&
typeof portalUrl === "string" &&
portalUrl.startsWith("http")
) {
window.open(portalUrl, "_blank");
} else {
throw new Error("No portal URL returned.");
}
} catch (err) {
toast(err.message, "error");
} finally {
if (btn) 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>PDF</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">&euro;&thinsp;${escHtml(String(inv.price ?? "—"))}</span>
<span class="invoice-dl">${inv.invoice_link ? `<a href="${escAttr(inv.invoice_link)}" target="_blank" rel="noopener" class="invoice-dl-link" title="Download invoice PDF"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></a>` : `<span class="invoice-dl-link invoice-dl-link--disabled">—</span>`}</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"];
for (const inv of filtered) {
rows.push(
[inv.date || "", inv.uuid || "", inv.product || "", inv.price || ""].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);
}
/* ─── Account Panel Tab Switching ─────────────────────────────── */
function switchAccountTab(tab) {
const settingsTab = document.getElementById("acct-tab-settings");
const affiliateTab = document.getElementById("acct-tab-affiliate");
const settingsPane = document.getElementById("acct-pane-settings");
const affiliatePane = document.getElementById("acct-pane-affiliate");
if (!settingsTab || !affiliateTab || !settingsPane || !affiliatePane) return;
if (tab === "affiliate") {
settingsTab.classList.remove("active");
affiliateTab.classList.add("active");
settingsPane.style.display = "none";
affiliatePane.style.display = "flex";
loadAffiliateLinks();
} else {
affiliateTab.classList.remove("active");
settingsTab.classList.add("active");
affiliatePane.style.display = "none";
settingsPane.style.display = "flex";
}
}
async function loadAffiliateLinks() {
const body = document.getElementById("affiliate-links-body");
if (!body) return;
body.innerHTML = '<div class="loading-row"><div class="spinner"></div></div>';
try {
const res = await apiFetch(AFFILIATE_LINKS_URL);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
dbg("\u2190 Affiliate links", data);
renderAffiliateLinks(data);
} catch (err) {
dbg("\u2190 Affiliate links error", err.message);
body.innerHTML =
'<p class="text-muted" style="font-size:13px;padding:4px 0;">Could not load affiliate links.</p>';
}
}
function renderAffiliateLinks(data) {
const body = document.getElementById("affiliate-links-body");
if (!body) return;
let areas = [];
if (data && Array.isArray(data.areas)) {
areas = data.areas;
} else if (data && Array.isArray(data.data)) {
const first = data.data[0];
if (first && Array.isArray(first.areas)) areas = first.areas;
} else if (Array.isArray(data)) {
const first = data[0];
if (first && Array.isArray(first.areas)) areas = first.areas;
}
body.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<span class="text-muted" style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;">${areas.length} areas</span>
<button type="button" id="save-affiliate-btn" class="btn btn-primary btn-sm" onclick="saveAffiliateLinks()" style="display:none;">
Save Changes
</button>
</div>
<div class="affiliate-areas-list">
${areas
.map(
(a) => `
<div class="affiliate-area-card">
<div class="affiliate-area-name">${escHtml(a.area)}</div>
<div class="form-row" style="margin:0;">
<div class="form-group" style="margin:0;">
<label>Text</label>
<input type="text" class="affiliate-input" data-area="${escAttr(a.area)}" data-field="text" value="${escAttr(a.text || "")}" oninput="affiliateAreaChanged()" placeholder="Affiliate text…" />
</div>
</div>
<div class="form-row" style="margin:0;">
<div class="form-group" style="margin:0;">
<label>Link</label>
<input type="text" class="affiliate-input" data-area="${escAttr(a.area)}" data-field="link" value="${escAttr(a.link || "")}" oninput="affiliateAreaChanged()" placeholder="https://…" />
</div>
</div>
</div>`,
)
.join("")}
</div>`;
}
function affiliateAreaChanged() {
const btn = document.getElementById("save-affiliate-btn");
if (btn) btn.style.display = "inline-flex";
}
async function saveAffiliateLinks() {
const btn = document.getElementById("save-affiliate-btn");
if (!btn) return;
btnLoad(btn, "Saving…");
try {
const inputs = document.querySelectorAll(".affiliate-input");
const map = {};
for (const inp of inputs) {
const area = inp.dataset.area;
const field = inp.dataset.field;
if (!map[area]) map[area] = { area, text: "", link: "" };
map[area][field] = inp.value;
}
const areas = Object.values(map);
const res = await apiFetch(AFFILIATE_LINKS_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ areas }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
toast("Affiliate links saved.", "success");
btn.style.display = "none";
loadAffiliateLinks();
} catch (err) {
toast(err.message, "error");
} finally {
btnReset(btn);
}
}
/* ═══════════════════════════════════════════════════════════════
RESTART
═══════════════════════════════════════════════════════════════ */
@@ -1086,8 +1531,30 @@ async function loadBackups(uuid) {
const body = document.getElementById("backups-body");
body.innerHTML = '<div class="loading-row"><div class="spinner"></div></div>';
// Check BACKUP flag from agent info response
const backupRaw = activeAgentInfo ? stripQuotes(activeAgentInfo.backup) : "—";
const hasBackup =
backupRaw !== "—" && backupRaw !== "0" && backupRaw !== "false";
const btn = document.getElementById("make-backup-btn");
if (btn) {
if (hasBackup) {
btn.style.display = "";
btn.innerHTML = "+";
btn.onclick = makeBackup;
btn.title = "Make Backup";
} else {
btn.style.display = "";
btn.innerHTML = "+";
btn.onclick = openBillingPortal;
btn.title = "Upgrade to enable backups";
btn.style.fontSize = "";
btn.style.lineHeight = "";
btn.style.padding = "";
}
}
try {
const _backupsUrl = `${BACKUP_LIST_URL}?uuid=${encodeURIComponent(uuid)}`;
const _backupsUrl = `${BACKUP_URL}?uuid=${encodeURIComponent(uuid)}`;
dbg("→ List Backups", _backupsUrl);
const res = await apiFetch(_backupsUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -1194,7 +1661,7 @@ async function restoreBackup(backup) {
try {
dbg("→ Restore Backup", BACKUP_URL);
const res = await apiFetch(BACKUP_URL, {
method: "POST",
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
uuid: activeUUID,
@@ -1244,72 +1711,50 @@ function renderContract(data) {
}
const item = data[0];
const type = (item.type || "").trim();
const expires = item.expires || "";
const product = stripQuotes(item.product || "");
const period = stripQuotes(item.period || "");
const type = stripQuotes(item.type || "");
// Determine status
const t = type.toLowerCase();
let expired = false;
if (expires && expires.trim()) {
try {
expired = new Date(expires) < new Date();
} catch (_) {}
}
// Look up expires from the agent data (new field in agents list API)
const agent = agents.find((a) => (a.UUID || a.uuid) === activeUUID);
const rawExpires =
agent && agent.expires
? String(agent.expires).replace(/^"|"$/g, "").trim()
: "";
const hasExpiry = rawExpires && rawExpires !== "null" && rawExpires !== "";
const expires = hasExpiry ? rawExpires : "";
let statusClass;
if (!type || expired || t.includes("none")) {
statusClass = "contract-dot--red";
} else if (t.includes("auto")) {
statusClass = "contract-dot--green";
} else {
statusClass = "contract-dot--yellow";
}
const label =
product && period
? `${product} \u2013 ${period}`
: product || period || type || "—";
// Update the header dot
headerDot.className = `contract-dot ${statusClass}`;
headerDot.className = hasExpiry
? "contract-dot contract-dot--red"
: "contract-dot contract-dot--green";
headerDot.style.visibility = "visible";
// Empty type — show placeholder row with extend button on the right
if (!type) {
body.innerHTML = `
<div class="contract-row">
<div class="contract-info">
<span class="contract-type">No contract</span>
<span class="contract-expires">Stripe payments can take up to 24hrs to be recognized.</span>
</div>
<a href="${escAttr(CONTRACT_EXTEND_URL)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm" style="margin-left:auto;flex-shrink:0;">Extend Contract</a>
</div>`;
return;
let html = `<div class="contract-row">
<div class="contract-info">
<span class="contract-type">${escHtml(label)}</span>`;
if (hasExpiry) {
html += `
<span class="contract-expires">expires ${escHtml(expires)}</span>`;
}
const expLabel =
expires && expires.trim()
? new Date(expires).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
: "No expiration";
html += `
</div>`;
const extendHtml =
statusClass === "contract-dot--red"
? `<a href="${escAttr(CONTRACT_EXTEND_URL)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm" style="margin-top:8px;display:inline-flex;">Extend Contract</a>`
: "";
if (hasExpiry) {
html += `
<a class="contract-extend-link" href="#" onclick="event.preventDefault();openBillingPortal()">Resubscribe &rarr;</a>`;
}
const cancelHtml =
statusClass === "contract-dot--green"
? `<a href="${escAttr(CONTRACT_CANCEL_URL)}" target="_blank" rel="noopener" class="btn btn-ghost btn-sm" style="margin-left:auto;flex-shrink:0;">Cancel</a>`
: "";
body.innerHTML = `
<div class="contract-row">
<div class="contract-info">
<span class="contract-type">${escHtml(type)}</span>
<span class="contract-expires">${escHtml(expLabel)}</span>
${extendHtml}
</div>
${cancelHtml}
html += `
</div>`;
body.innerHTML = html;
}
/* ═══════════════════════════════════════════════════════════════
@@ -1695,7 +2140,7 @@ let _confirmResolve = null;
function confirmDialog(title, message, okLabel = "Confirm") {
document.getElementById("confirm-title").textContent = title;
document.getElementById("confirm-message").textContent = message;
document.getElementById("confirm-message").innerHTML = message;
document.getElementById("confirm-ok-btn").textContent = okLabel;
document.getElementById("confirm-backdrop").classList.add("open");
return new Promise((resolve) => {
@@ -1732,7 +2177,7 @@ function toast(msg, type = "info", duration = 4000) {
/* ─── Cookie helpers ───────────────────────────────────────── */
function setCookie(name, value, days) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Strict`;
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; domain=.derez.ai; SameSite=Lax`;
}
function getCookie(name) {
return document.cookie.split("; ").reduce((acc, c) => {
@@ -1741,7 +2186,7 @@ function getCookie(name) {
}, null);
}
function deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Strict`;
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=.derez.ai; SameSite=Lax`;
}
/* ─── Authenticated fetch ───────────────────────────────────── */
@@ -1861,6 +2306,8 @@ async function sendPasswordReset() {
} catch (_) {
// Always show sent — never reveal whether the address exists
} finally {
// Hide the red error panel — it's no longer relevant once reset is requested
document.getElementById("signin-error").style.display = "none";
document.getElementById("reset-pw-idle").style.display = "none";
document.getElementById("reset-pw-sent").style.display = "flex";
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+338 -79
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>derez.ai — My Portal</title>
<link rel="stylesheet" href="styles.css" />
</head>
@@ -247,6 +248,10 @@
>
View plans &amp; pricing &rarr;
</a>
<p class="hire-note">
New clients — a reset link has already been sent to
your email.
</p>
</div>
</div>
<!-- /auth-pane signup -->
@@ -329,84 +334,6 @@
<span id="user-email-label"></span>
</span>
<!-- ── User account panel ────────────────────── -->
<div
id="user-panel"
class="user-panel"
style="display: none"
onclick="event.stopPropagation()"
>
<div class="user-panel-header">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
</svg>
Account
</div>
<div class="user-panel-row">
<span class="user-panel-label">Email</span>
<span
class="user-panel-val"
id="user-panel-email-val"
></span
>
</div>
<div class="user-panel-divider"></div>
<div class="user-panel-pw">
<div class="form-group">
<label>Change Password</label>
<div class="pw-input-wrap">
<input
type="password"
id="acct-pw-input"
placeholder="New password"
autocomplete="new-password"
oninput="
toggleAccountPwSave(this.value)
"
onkeydown="
if (event.key === 'Enter')
saveAccountPassword();
"
/>
<button
type="button"
id="acct-pw-save-btn"
class="pw-save-btn"
onclick="saveAccountPassword()"
title="Save password"
style="display: none"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
</div>
</div>
<p class="ssh-hint" style="margin-bottom: 0">
Min. 8 characters.
</p>
</div>
</div>
<button class="btn btn-ghost btn-sm" onclick="doSignOut()">
<svg
width="13"
@@ -571,7 +498,7 @@
d="M7 11V7a5 5 0 0 1 10 0v4"
/>
</svg>
Instance Password
Your Hermes &amp; Root Password
</span>
</div>
<div class="pw-wrap">
@@ -875,6 +802,338 @@
</div>
<!-- /app-shell -->
<!-- ═══════════════════════════════════════════════════════════
ACCOUNT SETTINGS MODAL
════════════════════════════════════════════════════════════════ -->
<div
id="account-backdrop"
class="modal-backdrop"
onclick="if (event.target === this) closeUserPanel();"
>
<div class="modal account-modal">
<div class="modal-header">
<div class="auth-switch">
<button
type="button"
id="acct-tab-settings"
class="auth-switch-btn active"
onclick="switchAccountTab('settings')"
>
Account Settings
</button>
<button
type="button"
id="acct-tab-affiliate"
class="auth-switch-btn"
onclick="switchAccountTab('affiliate')"
>
Affiliate Settings
</button>
</div>
<button
type="button"
class="modal-close-btn"
onclick="closeUserPanel()"
>
&#x2715;
</button>
</div>
<div class="account-modal-body">
<!-- ── Pane: Settings ──────────────────────── -->
<div
id="acct-pane-settings"
class="account-pane"
style="display: flex"
>
<div class="account-col account-col--settings">
<div class="acct-section">
<div class="acct-field-label">Email</div>
<div
class="acct-field-val"
id="acct-email-display"
>
</div>
</div>
<div class="acct-divider"></div>
<div class="acct-section">
<div class="form-group">
<label>Change Password</label>
<div class="pw-input-wrap">
<input
type="password"
id="acct-pw-input"
placeholder="New password"
autocomplete="new-password"
oninput="
toggleAccountPwSave(this.value)
"
onkeydown="
if (event.key === 'Enter')
saveAccountPassword();
"
/>
<button
type="button"
id="acct-pw-save-btn"
class="pw-save-btn"
onclick="saveAccountPassword()"
title="Save password"
style="display: none"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline
points="20 6 9 17 4 12"
/>
</svg>
</button>
</div>
<p
class="ssh-hint"
style="margin-bottom: 0"
>
Min. 8 characters.
</p>
</div>
</div>
<div class="acct-divider"></div>
<div class="acct-section">
<div class="form-group">
<label>Invoice Email</label>
<div class="pw-input-wrap">
<input
type="email"
id="invoice-email-input"
placeholder="invoices@example.com"
autocomplete="email"
oninput="
toggleInvoiceEmailSave(
this.value,
)
"
onkeydown="
if (event.key === 'Enter')
saveInvoiceEmail();
"
/>
<button
type="button"
id="invoice-email-save-btn"
class="pw-save-btn"
onclick="saveInvoiceEmail()"
title="Save invoice email"
style="display: none"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline
points="20 6 9 17 4 12"
/>
</svg>
</button>
</div>
</div>
</div>
<div class="acct-divider"></div>
<div class="acct-section">
<div class="acct-field-label">
Manage Subscriptions
</div>
<div
class="acct-affiliate-row"
style="justify-content: flex-end"
>
<button
type="button"
id="portal-btn"
class="btn btn-ghost btn-sm"
onclick="openBillingPortal()"
>
Portal &rarr;
</button>
</div>
</div>
<div class="acct-divider"></div>
</div>
<!-- ── Vertical divider ──────────────────────── -->
<div class="account-col-divider"></div>
<!-- ── Right: invoices ──────────────────────── -->
<div class="account-col account-col--invoices">
<div class="acct-invoices-toolbar">
<div class="acct-invoices-title">Invoices</div>
<div class="acct-invoices-actions">
<input
type="text"
id="invoice-filter"
class="invoice-filter-input"
placeholder="Filter invoices…"
oninput="applyInvoiceFilter()"
/>
<button
type="button"
class="btn btn-ghost btn-sm"
onclick="exportInvoicesCSV()"
>
Export CSV
</button>
</div>
</div>
<div id="invoices-body"></div>
</div>
</div>
<!-- ── Pane: Affiliate ─────────────────────── -->
<div
id="acct-pane-affiliate"
class="account-pane"
style="display: none"
>
<div
class="account-col"
style="
flex: 1;
padding: 18px 20px;
overflow-y: auto;
"
>
<!-- Affiliate Code -->
<div class="acct-section">
<div class="acct-field-label">
Affiliate Code
</div>
<div class="acct-affiliate-row">
<span
class="acct-affiliate-code"
id="acct-affiliate-code"
></span
>
<button
type="button"
id="acct-affiliate-copy-btn"
class="btn btn-ghost btn-sm"
onclick="copyAffiliateCode()"
style="display: none"
>
Copy Affiliate Link
</button>
</div>
</div>
<div class="acct-divider"></div>
<!-- Affiliate Links Table -->
<div
class="acct-invoices-toolbar"
style="margin-top: 2px"
>
<div class="acct-invoices-title">
Affiliate Links
</div>
</div>
<div id="affiliate-links-body">
<p
class="text-muted"
style="font-size: 13px; padding: 4px 0"
>
Switch to this tab to see available
affiliate links.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
BUY CREDITS MODAL
════════════════════════════════════════════════════════════════ -->
<div id="credits-backdrop" class="modal-backdrop">
<div class="modal" style="max-width: 460px">
<div class="modal-header">
<span class="modal-title">Buy Credits</span>
</div>
<div class="modal-body">
<p class="credits-key-label">
Credits will be assigned to key:
<span id="credits-key-name"></span>
</p>
<div class="credits-options">
<label class="credits-option">
<input
type="radio"
name="credits-amount"
value="11.00"
/>
<span class="credits-option-label"
>&euro;&thinsp;11.00</span
>
</label>
<label class="credits-option">
<input
type="radio"
name="credits-amount"
value="22.00"
/>
<span class="credits-option-label"
>&euro;&thinsp;22.00</span
>
</label>
<label class="credits-option">
<input
type="radio"
name="credits-amount"
value="55.00"
/>
<span class="credits-option-label"
>&euro;&thinsp;55.00</span
>
</label>
<label class="credits-option">
<input
type="radio"
name="credits-amount"
value="110.00"
/>
<span class="credits-option-label"
>&euro;&thinsp;110.00</span
>
</label>
</div>
<p class="credits-fee-note">
A 10% handling fee is added on top of the OpenRouter
credits value.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="buyCreditsClose()">
Cancel
</button>
<button
id="credits-buy-btn"
class="btn btn-primary"
onclick="buyCreditsSubmit()"
>
Buy Credits
</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
CONFIRM DIALOG
════════════════════════════════════════════════════════════════ -->
+27
View File
@@ -0,0 +1,27 @@
{
"name": "GitHub Repo",
"description": "Set up and sync with a GitHub repository — clone, commit, push, and manage code for your Hermes agent.",
"fields": [
{
"label": "Repository URL",
"key": "repo_url",
"type": "text",
"placeholder": "https://github.com/your-username/your-repo.git"
},
{
"label": "GitHub Username",
"key": "git_user",
"type": "text",
"placeholder": "your-username"
},
{
"label": "GitHub Password / Personal Access Token",
"key": "git_pass",
"type": "password",
"placeholder": "ghp_xxxxxxxxxxxx"
}
],
"signup_url": "https://semrush.com",
"signup_label": "🤝 Need to drive traffic to your site? Create a Semrush account",
"prompt": "Create a Hermes agent skill named \"github_repo_setup\".\n\n## Purpose\nSet up a working GitHub repository that the Hermes agent can use to read, write, and manage code, content, or configuration files via git. The repo acts as a persistent file store and version-controlled workspace.\n\n## Credentials\n\nREPO_URL={repo_url}\nGIT_USER={git_user}\nGIT_PASS={git_pass}\n\n## Startup behaviour\n\n1. On first run, check if git is installed. If not, install it:\n apt-get update && apt-get install -y git\n Or the equivalent for the system's package manager.\n\n2. Check if the repo directory (~/repo) already exists and is a git repo.\n cd ~/repo && git status\n\n3. If ~/repo does not exist or is not a git repo:\n - Extract the repo name from REPO_URL (the part before .git).\n - If a remote git URL is provided, authenticate using GIT_USER and GIT_PASS in the URL:\n AUTH_URL=$(echo \"$REPO_URL\" | sed \"s|https://|https://$GIT_USER:$GIT_PASS@|\")\n - Clone the repo:\n git clone \"$AUTH_URL\" ~/repo\n - Set the remote origin to the AUTH_URL for future push/pull:\n git remote set-url origin \"$AUTH_URL\"\n\n4. If ~/repo exists but is not a git repo, print an error and halt.\n\n5. If ~/repo is already a valid git repo and has a remote origin:\n - Re-authenticate the remote with the provided credentials:\n AUTH_URL=$(echo \"$REPO_URL\" | sed \"s|https://|https://$GIT_USER:$GIT_PASS@|\")\n git remote set-url origin \"$AUTH_URL\"\n - Pull the latest changes:\n git pull --ff-only\n - Print the status: \"GitHub repo already set up. Latest changes pulled.\"\n\n6. After clone or pull, print the current git log (last 5 commits):\n git --no-pager log --oneline -5\n\n## Daily usage\n\nWhenever the agent needs to read, write, or edit files:\n\n### Read a file\n cat ~/repo/<path>\n\n### Edit / write a file\n Write the new content, then:\n cd ~/repo\n git add <path>\n git commit -m \"<descriptive message>\"\n git push\n\n### Commit message format\n Always use conventional commits:\n - feat: <description>\n - fix: <description>\n - docs: <description>\n - chore: <description>\n - refactor: <description>\n\n### Push discipline\n- Always pull before push:\n git pull --ff-only && git push\n- If pull fails due to divergent branches, fetch and rebase:\n git fetch origin && git rebase origin/$(git branch --show-current) && git push\n- If rebase has conflicts, abort and notify the user:\n git rebase --abort\n echo \"ERROR: Merge conflict — needs manual resolution.\"\n\n### Create a new branch\n git checkout -b <branch-name>\n git push -u origin <branch-name>\n\n### List branches\n git branch -a\n\n### Working directory discipline\n- All work goes inside ~/repo. Never write files outside ~/repo unless explicitly instructed.\n- After every batch of changes, commit and push.\n\n## Solved cases log\n\nAfter every successfully completed task that involved the GitHub repo, append to ~/repo_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Commands: <the git commands that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/repo_solutions.md at the start of every task — if a matching case exists, reuse it directly."
}
+31 -6
View File
@@ -2,13 +2,38 @@
"name": "IMAP / SMTP Email",
"description": "Read, send, and manage email from any standard mailbox.",
"fields": [
{"label": "Email Address", "key": "email", "type": "text", "placeholder": "you@yourdomain.com"},
{"label": "Your Name", "key": "display_name", "type": "text", "placeholder": "Jane Smith"},
{"label": "IMAP Host", "key": "imap_host", "type": "text", "placeholder": "imap.hostinger.com"},
{"label": "SMTP Host", "key": "smtp_host", "type": "text", "placeholder": "smtp.hostinger.com"},
{"label": "Password", "key": "password", "type": "password", "placeholder": ""}
{
"label": "Email Address",
"key": "email",
"type": "text",
"placeholder": "you@yourdomain.com"
},
{
"label": "Your Name",
"key": "display_name",
"type": "text",
"placeholder": "Jane Smith"
},
{
"label": "IMAP Host",
"key": "imap_host",
"type": "text",
"placeholder": "imap.hostinger.com"
},
{
"label": "SMTP Host",
"key": "smtp_host",
"type": "text",
"placeholder": "smtp.hostinger.com"
},
{
"label": "Password",
"key": "password",
"type": "password",
"placeholder": ""
}
],
"signup_url": "https://hostinger.com",
"signup_label": "Need a domain or email? Sign up at Hostinger",
"signup_label": "🤝 Need a domain or email? Sign up at Hostinger",
"prompt": "Create a skill for IMAP / SMTP Email.\n\nUse the himalaya CLI to read, send, and manage emails.\n\n## Install himalaya (if not already installed)\n\ncurl -sSL https://raw.githubusercontent.com/pimalaya/himalaya/master/install.sh | PREFIX=~/.local sh\nexport PATH=\"$HOME/.local/bin:$PATH\" # add to ~/.bashrc if not present\nhimalaya --version\n\n## Write the config file\n\nCreate ~/.config/himalaya/config.toml with exactly this content:\n\n[accounts.default]\ndefault = true\nemail = \"{email}\"\ndisplay-name = \"{display_name}\"\n\nfolder.aliases.inbox = \"INBOX\"\nfolder.aliases.sent = \"Sent\"\nfolder.aliases.drafts = \"Drafts\"\nfolder.aliases.trash = \"Trash\"\n\nbackend.type = \"imap\"\nbackend.host = \"{imap_host}\"\nbackend.port = 993\nbackend.encryption.type = \"tls\"\nbackend.login = \"{email}\"\nbackend.auth.type = \"password\"\nbackend.auth.raw = \"{password}\"\n\nmessage.send.backend.type = \"smtp\"\nmessage.send.backend.host = \"{smtp_host}\"\nmessage.send.backend.port = 587\nmessage.send.backend.encryption.type = \"start-tls\"\nmessage.send.backend.login = \"{email}\"\nmessage.send.backend.auth.type = \"password\"\nmessage.send.backend.auth.raw = \"{password}\"\n\n## Key commands\n\nhimalaya folder list\nhimalaya envelope list\nhimalaya envelope list -f Sent\nhimalaya message read <id>\nhimalaya message reply <id>\nhimalaya message forward <id>\nhimalaya message delete <id>\nhimalaya --output json envelope list\n\n## Sending email\n\nprintf 'From: {display_name} <{email}>\\nTo: recipient@example.com\\nSubject: Hello\\n\\nBody text here.' | himalaya message send\n\n## Solved cases log\n\nEvery time you successfully solve an email task, append a short entry to ~/email_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Key commands: <the himalaya commands or shell pipeline that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/email_solutions.md at the start of each task — if a matching solved case exists, reuse it directly."
}
+3 -1
View File
@@ -2,5 +2,7 @@
"odoo-community.json",
"imap-smtp-email.json",
"n8n-crm.json",
"nextcloud-sync.json"
"nextcloud-sync.json",
"n8n-skills.json",
"github-repo.json"
]
+25 -5
View File
@@ -2,12 +2,32 @@
"name": "N8N CRM",
"description": "Manage contacts and deal stages via your n8n webhook CRM.",
"fields": [
{"label": "Webhook Base URL", "key": "webhook_url", "type": "text", "placeholder": "https://your-n8n.example.com/webhook/crm"},
{"label": "Auth Header Name", "key": "auth_header_name", "type": "text", "placeholder": "Authorization"},
{"label": "Auth Header Value", "key": "auth_header_value", "type": "password", "placeholder": ""},
{"label": "Example Contact JSON (paste one record from your CRM)", "key": "example_json", "type": "textarea", "placeholder": "{\n \"email\": \"alice@example.com\",\n \"name\": \"Alice Smith\",\n \"company\": \"Acme Corp\",\n \"stage\": \"Cold\",\n \"followup\": false,\n \"notes\": \"\"\n}"}
{
"label": "Webhook Base URL",
"key": "webhook_url",
"type": "text",
"placeholder": "https://your-n8n.example.com/webhook/crm"
},
{
"label": "Auth Header Name",
"key": "auth_header_name",
"type": "text",
"placeholder": "Authorization"
},
{
"label": "Auth Header Value",
"key": "auth_header_value",
"type": "password",
"placeholder": ""
},
{
"label": "Example Contact JSON (paste one record from your CRM)",
"key": "example_json",
"type": "textarea",
"placeholder": "{\n \"email\": \"alice@example.com\",\n \"name\": \"Alice Smith\",\n \"company\": \"Acme Corp\",\n \"stage\": \"Cold\",\n \"followup\": false,\n \"notes\": \"\"\n}"
}
],
"signup_url": "https://n8n.io",
"signup_label": "Need a webhook backend? Try n8n — self-host or cloud",
"signup_label": "🤝 Need a webhook backend? Try n8n — self-host or cloud",
"prompt": "Create a skill for an N8N CRM backed by a webhook.\n\n## Configuration\n\nWEBHOOK_URL={webhook_url}\nAUTH_HEADER_NAME={auth_header_name}\nAUTH_HEADER_VALUE={auth_header_value}\n\n## Example record schema (auto-detected from user input)\n\n{example_json}\n\nParse the keys of the JSON above to discover the available columns. Always use exactly those column names when building payloads or displaying data. The column named \"email\" is the unique identifier for every contact.\n\n## Stages\n\n| Stage | Meaning |\n|------------|--------------------------------|\n| Cold | No contact so far |\n| Contacted | First message / email sent |\n| Warm | Response received |\n| Won | Deal closed successfully |\n| Lost | Deal closed unsuccessfully |\n\n## API contract\n\n### List contacts\n\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL\"\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL?stage=Warm\"\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL?email=alice@example.com\"\n curl -s -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \"$WEBHOOK_URL?followup=true\"\n\n### Create or update a contact (upsert by email)\n\n curl -s -X POST \"$WEBHOOK_URL\" \\\n -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"email\":\"alice@example.com\",\"name\":\"Alice Smith\",\"stage\":\"Contacted\",\"followup\":false}'\n\n### Delete a contact\n\n curl -s -X DELETE \"$WEBHOOK_URL\" \\\n -H \"$AUTH_HEADER_NAME: $AUTH_HEADER_VALUE\" \\\n -H 'Content-Type: application/json' \\\n -d '{\"email\":\"alice@example.com\"}'\n\n## Solved cases log\n\nAfter every successful CRM task, append an entry to ~/crm_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Key commands: <the curl | jq pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/crm_solutions.md at task start — if a matching case exists, reuse it directly."
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "N8N Skills",
"description": "Load and execute skills from n8n's official skill library via the n8n REST API.",
"fields": [
{
"label": "n8n URL",
"key": "n8n_url",
"type": "text",
"placeholder": "https://your-n8n.example.com \u2014 API endpoint is usually at <domain>/api/v1"
},
{
"label": "n8n API Key",
"key": "n8n_api_key",
"type": "password",
"placeholder": ""
}
],
"signup_url": "https://n8n.io/?via=derez",
"signup_label": "🤝 Need n8n? Sign up via our affiliate link",
"prompt": "Create a skill that integrates with n8n's skill library and REST API.\n\n## Configuration\n\nN8N_URL={n8n_url}\nN8N_API_KEY={n8n_api_key}\n\n## Skill library\n\nYou have access to n8n's official skill library at https://github.com/n8n-io/skills\n\nAt the start of every task, clone or pull the latest version of this repo to discover available skills.\n\n if [ ! -d /tmp/n8n-skills ]; then\n git clone --depth 1 https://github.com/n8n-io/skills /tmp/n8n-skills\n else\n cd /tmp/n8n-skills && git pull --ff-only\n fi\n\nRead the README.md and INDEX.md files in that repo to understand what skills are available. Each skill has a dedicated directory with a README.md describing its purpose and usage. Choose the most relevant skill(s) for the user's request.\n\n## API basics\n\nAll calls go to {n8n_url}/rest/... with the header:\n X-N8N-API-KEY: {n8n_api_key}\n\nThe n8n REST API documentation is available at {n8n_url}/api/v1/openapi.json — fetch it if you need to discover available endpoints.\n\n### Common operations\n\nList workflows:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/workflows'\n\nGet a workflow by ID:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/workflows/<id>'\n\nList executions:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/executions'\n\nList credentials:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/credentials'\n\nList tags:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/tags'\n\n## Workflow skill execution pattern\n\nWhen a skill from the library maps to a workflow to run:\n\n1. Read the skill's README.md to understand the inputs, outputs, and workflow structure.\n2. Check if a matching workflow already exists in n8n (use the list endpoint).\n3. If it does, create an execution:\n\n curl -s -X POST '{n8n_url}/rest/workflows/<id>/run' \\\n -H 'X-N8N-API-KEY: {n8n_api_key}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"data\": <input_data>}'\n\n4. If it doesn't, create a new workflow using the skill's specification:\n\n curl -s -X POST '{n8n_url}/rest/workflows' \\\n -H 'X-N8N-API-KEY: {n8n_api_key}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"name\": \"<skill-name>\", \"nodes\": [...], \"connections\": {...}}'\n\n5. Poll execution status:\n curl -s -H 'X-N8N-API-KEY: {n8n_api_key}' '{n8n_url}/rest/executions/<id>' | jq '.data'\n\n## Solved cases log\n\nAfter every successful n8n task, append to ~/n8n_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Skill used: <skill path in the library>\n Key commands: <the curl | jq pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/n8n_solutions.md at task start — if a matching case exists, reuse it directly."
}
+26 -6
View File
@@ -2,12 +2,32 @@
"name": "Nextcloud Sync",
"description": "Connect to a Nextcloud instance and sync folders via WebDAV — list, download, upload, and mirror files automatically.",
"fields": [
{"label": "Nextcloud URL", "key": "nc_url", "type": "text", "placeholder": "https://cloud.example.com"},
{"label": "Username", "key": "nc_user", "type": "text", "placeholder": "admin"},
{"label": "Password / App Token", "key": "nc_pass", "type": "password", "placeholder": ""},
{"label": "Directory to sync (optional — leave blank for all)", "key": "nc_dir", "type": "text", "placeholder": "/Documents"}
{
"label": "Nextcloud URL",
"key": "nc_url",
"type": "text",
"placeholder": "https://cloud.example.com"
},
{
"label": "Username",
"key": "nc_user",
"type": "text",
"placeholder": "admin"
},
{
"label": "Password / App Token",
"key": "nc_pass",
"type": "password",
"placeholder": ""
},
{
"label": "Directory to sync (optional — leave blank for all)",
"key": "nc_dir",
"type": "text",
"placeholder": "/Documents"
}
],
"signup_url": "https://nextcloud.com/de/",
"signup_label": "Don't have Nextcloud yet? Get started at nextcloud.com",
"prompt": "You are a Nextcloud file-sync agent. Your only tools are curl, jq, and standard POSIX shell utilities. Never write Python or Node scripts. Every task must be solved with shell one-liners or short shell scripts.\n\n## Credentials\n\nNC_URL={nc_url}\nNC_USER={nc_user}\nNC_PASS={nc_pass}\nNC_DIR={nc_dir}\n\n## WebDAV base path\n\nAll WebDAV operations use:\n WEBDAV=\"$NC_URL/remote.php/dav/files/$NC_USER\"\n\nIf NC_DIR is set and non-empty, the sync root is:\n SYNC_ROOT=\"$WEBDAV$NC_DIR\"\nOtherwise the sync root is the full remote home:\n SYNC_ROOT=\"$WEBDAV\"\n\n## Common operations\n\n### List folder contents (one level)\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -X PROPFIND \\\n -H 'Depth: 1' \\\n -H 'Content-Type: application/xml' \\\n --data '<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:displayname/><d:getcontentlength/><d:getlastmodified/><d:resourcetype/></d:prop></d:propfind>' \\\n \"$SYNC_ROOT/\" \\\n | grep -oP '(?<=<d:href>)[^<]+'\n\n### Recursively list all remote paths\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -X PROPFIND \\\n -H 'Depth: infinity' \\\n -H 'Content-Type: application/xml' \\\n --data '<?xml version=\"1.0\"?><d:propfind xmlns:d=\"DAV:\"><d:prop><d:href/><d:resourcetype/></d:prop></d:propfind>' \\\n \"$SYNC_ROOT/\" \\\n | grep -oP '(?<=<d:href>)[^<]+'\n\n### Download a single file\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -o \"/local/path/filename.ext\" \\\n \"$NC_URL/remote.php/dav/files/$NC_USER/path/to/filename.ext\"\n\n### Upload a single file\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -T \"/local/path/filename.ext\" \\\n \"$NC_URL/remote.php/dav/files/$NC_USER/path/to/filename.ext\"\n\n### Create a remote directory\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -X MKCOL \\\n \"$NC_URL/remote.php/dav/files/$NC_USER/path/to/new_folder\"\n\n### Delete a remote file or folder\n\n curl -s -u \"$NC_USER:$NC_PASS\" \\\n -X DELETE \\\n \"$NC_URL/remote.php/dav/files/$NC_USER/path/to/item\"\n\n## Sync strategy\n\nWhen asked to sync a folder:\n1. List all remote paths under SYNC_ROOT recursively.\n2. Mirror the directory tree locally, creating subdirectories as needed.\n3. For each remote file, compare the Last-Modified header against the local mtime (use `date -r` or `stat -c %Y`). Download only if remote is newer or file is absent locally.\n4. For uploads, scan the local tree and PUT any file that is newer locally than the remote copy or does not yet exist remotely.\n5. Never delete files on either side without explicit user confirmation.\n\n## App tokens (recommended over plain password)\n\nGenerate an app token at: $NC_URL/settings/user/security\nUse the token in place of the password — it can be revoked without changing the account password.\n\n## Solved cases log\n\nAfter every successful sync or file operation, append to ~/nextcloud_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Remote path: <SYNC_ROOT value used>\n Key commands: <the curl pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/nextcloud_solutions.md at task start — if a matching case exists, reuse it directly."
"signup_label": "🤝 Don't have Nextcloud yet? Get started at nextcloud.com",
"prompt": "\n Create a Hermes agent skill named \"nextcloud_exchange_bisync\".\n \n Purpose:\n Create a skill that synchronizes data bidirectionally between a user's Nextcloud instance and a Nextcloud directory named \"exchange\" using rclone.\n \n Skill requirements:\n \n 1. Startup behavior:\n - When the skill starts, print a clear status message:\n \"Starting Nextcloud exchange synchronization...\"\n - Check whether rclone is installed.\n - If rclone is missing, install it automatically using the official rclone installation method.\n - Verify that rclone is available before continuing.\n \n 2. \"Credentials\"\n Your credentials are below. store them for future use\n Credentials\n NC_URL={nc_url}\nNC_USER={nc_user}\nNC_PASS={nc_pass}\n \n 3. rclone configuration:\n - Automatically create rclone WebDAV remotes for Nextcloud.\n - Configure Nextcloud as the WebDAV provider.\n - Use:\n vendor=nextcloud\n - Store configuration in the standard rclone config location.\n - Reuse existing configuration if already present.\n\n \n 4. Synchronization:\n rclone bisync /root/exchange/ nextcloud:/{nc_dir} --resync\n you need to start the sync manually. do this before every access to the directory and after any changes you want to sync.\n \n 5. Safety:\n - Enable checks before syncing.\n - Detect access problems before starting.\n - Preserve timestamps.\n - Preserve empty directories.\n - Avoid accidental deletion unless confirmed by bisync logic.\n - Produce readable logs.\n \n 6. Completion:\n When finished:\n - Print:\n \"Nextcloud exchange synchronization completed.\"\n - Provide a short summary:\n - files checked\n - changes transferred\n - errors if any\n \n 7. Error handling:\n - Fail cleanly.\n - Explain missing credentials.\n - Explain connection failures.\n - Explain rclone errors.\n - Return non-zero status on failure.\n \n Generate:\n - The complete Hermes skill definition.\n - The executable script.\n - Configuration schema.\n - Required environment variables.\n - Usage example."
}
+25 -5
View File
@@ -2,12 +2,32 @@
"name": "ODOO Community",
"description": "Automate ERP tasks — sales, accounting, and inventory — via REST API.",
"fields": [
{"label": "Site URL", "key": "site_url", "type": "text", "placeholder": "https://ODOO4projects.com"},
{"label": "Username", "key": "username", "type": "text", "placeholder": "admin"},
{"label": "Password / API Key", "key": "api_key", "type": "password", "placeholder": ""},
{"label": "Database Name", "key": "db_name", "type": "text", "placeholder": "mycompany"}
{
"label": "Site URL",
"key": "site_url",
"type": "text",
"placeholder": "https://ODOO4projects.com"
},
{
"label": "Username",
"key": "username",
"type": "text",
"placeholder": "admin"
},
{
"label": "Password / API Key",
"key": "api_key",
"type": "password",
"placeholder": ""
},
{
"label": "Database Name",
"key": "db_name",
"type": "text",
"placeholder": "mycompany"
}
],
"signup_url": "https://ODOO4projects.com",
"signup_label": "Need ODOO Community hosting? Sign up here",
"signup_label": "🤝 Need ODOO Community hosting? Sign up here",
"prompt": "create a skill for ODOO community. You are an Odoo automation agent. Your only tools are curl and jq. Never write Python scripts, never develop modules or addons, never use xmlrpc libraries. Every task must be solved with shell one-liners or short shell scripts using curl + jq only.\n\n## Credentials\n\nSITE_URL={site_url}\nUSER={username}\nAPI_KEY={api_key}\nDB={db_name}\n\n## API call pattern\n\nEvery call is a POST to {site_url}/json/2/<model>/<method>\nRequired headers on every request:\n Authorization: Bearer {api_key}\n X-Openerp-Database: {db_name}\n Content-Type: application/json\n\nBody: a FLAT JSON object of the method arguments (NOT JSON-RPC — no \"params\" wrapper).\nResponse: the raw return value directly (no \"result\" wrapper).\n\n## Always request only the fields you need\n\nNever call search_read without a \"fields\" list. Fetching all fields is wasteful and slow.\nDecide upfront which fields the task actually requires, then pass exactly those.\n\n curl -s -X POST '{site_url}/json/2/sale.order/search_read' \\\n -H 'Authorization: Bearer {api_key}' \\\n -H 'X-Openerp-Database: {db_name}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"domain\": [[\"state\",\"=\",\"sale\"]], \"fields\": [\"name\",\"partner_id\",\"amount_total\"], \"limit\": 20}' \\\n | jq '.[] | {order: .name, customer: .partner_id[1], total: .amount_total}'\n\nUse fields_get only when you genuinely do not know a model's field names:\n curl -s -X POST '{site_url}/json/2/account.move/fields_get' \\\n -H 'Authorization: Bearer {api_key}' \\\n -H 'X-Openerp-Database: {db_name}' \\\n -H 'Content-Type: application/json' \\\n -d '{\"attributes\": [\"string\",\"type\"]}' \\\n | jq 'to_entries[] | {field: .key, label: .value.string, type: .value.type}'\n\n## First-run: build your API digest\n\nIf ~/odoo_digest.md does NOT exist yet, run this once and write the output to the file:\n\n curl -s '{site_url}/json/2/doc' \\\n -H 'Authorization: Bearer {api_key}' \\\n -H 'X-Openerp-Database: {db_name}' \\\n | jq '[to_entries[] | {model: .key, label: .value.string}]' > /tmp/odoo_models.json\n\nFrom /tmp/odoo_models.json, write ~/odoo_digest.md containing:\n - The standard ORM methods available on every model\n - A compact table of installed business models with their technical name and human label\n - The exact one-liner curl template for POST calls\n - Any non-standard or custom routes found in /doc\n\nOn every future task, read ~/odoo_digest.md first.\n\n## Solved cases log\n\nAfter successfully solving any business task, append to ~/odoo_solutions.md:\n\n ## <short title> (<date>)\n Goal: <one sentence>\n Models: <models used>\n Fields queried: <only the fields that were actually needed>\n Key calls: <the curl | jq pipelines that worked>\n Gotchas: <anything non-obvious>\n\nRead ~/odoo_solutions.md at task start — if a matching case exists, reuse it directly."
}
+432 -153
View File
@@ -3,28 +3,35 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>derez.ai — Reset Password</title>
<style>
/* ─── Reset ─────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ─── Design tokens (Digital Grid theme) ────────────── */
:root {
--bg: #000810;
--bg-card: #010f20;
--bg-inner: #000d1a;
--bg-hover: #001a30;
--bg-input: #000813;
--border: #0a3060;
--border-hi: rgba(0, 245, 255, 0.45);
--accent: #00f5ff;
--bg: #000810;
--bg-card: #010f20;
--bg-inner: #000d1a;
--bg-hover: #001a30;
--bg-input: #000813;
--border: #0a3060;
--border-hi: rgba(0, 245, 255, 0.45);
--accent: #00f5ff;
--accent-dim: #002535;
--danger: #ff3b3b;
--danger: #ff3b3b;
--danger-dim: #3b0000;
--success: #00ff88;
--text: #c8f0ff;
--success: #00ff88;
--text: #c8f0ff;
--text-muted: #2e6a80;
--radius: 8px;
--radius: 8px;
--shadow:
0 0 0 1px rgba(0, 245, 255, 0.25),
0 0 60px rgba(0, 245, 255, 0.12),
@@ -32,7 +39,8 @@
}
/* ─── Base ───────────────────────────────────────────── */
html, body {
html,
body {
min-height: 100%;
height: 100%;
font-family: "Courier New", Courier, monospace;
@@ -52,27 +60,130 @@
inset: 0;
background:
/* Stars */
radial-gradient(circle 5px at 5% 8%, white 0%, white 40%, rgba(180,245,255,.28) 72%, transparent 100%),
radial-gradient(circle 3px at 15% 5%, white 0%, white 42%, transparent 100%),
radial-gradient(circle 6px at 31% 4%, white 0%, white 38%, rgba(150,220,255,.32) 70%, transparent 100%),
radial-gradient(circle 3px at 47% 6%, white 0%, white 42%, transparent 100%),
radial-gradient(circle 4px at 54% 3%, rgba(190,245,255,1) 0%, rgba(190,245,255,1) 42%, rgba(190,245,255,.28) 70%, transparent 100%),
radial-gradient(circle 5px at 71% 7%, white 0%, white 40%, rgba(180,245,255,.26) 70%, transparent 100%),
radial-gradient(circle 3px at 87% 9%, white 0%, white 42%, transparent 100%),
radial-gradient(circle 4px at 94% 5%, rgba(190,245,255,1) 0%, rgba(190,245,255,1) 42%, transparent 100%),
radial-gradient(circle 3px at 18% 16%, white 0%, white 42%, transparent 100%),
radial-gradient(circle 4px at 57% 17%, rgba(180,240,255,1) 0%, rgba(180,240,255,1) 42%, rgba(180,240,255,.26) 70%, transparent 100%),
radial-gradient(circle 3px at 75% 15%, white 0%, white 42%, transparent 100%),
radial-gradient(circle 2px at 9% 22%, rgba(255,255,255,.65) 0%, rgba(255,255,255,.65) 45%, transparent 100%),
radial-gradient(circle 2px at 40% 25%, rgba(255,255,255,.55) 0%, rgba(255,255,255,.55) 45%, transparent 100%),
radial-gradient(circle 2px at 66% 23%, rgba(255,255,255,.55) 0%, rgba(255,255,255,.55) 45%, transparent 100%),
radial-gradient(circle 2px at 83% 20%, rgba(160,225,255,.68) 0%, rgba(160,225,255,.68) 45%, transparent 100%),
radial-gradient(circle 3px at 33% 38%, rgba(170,230,255,.52) 0%, rgba(170,230,255,.52) 42%, transparent 100%),
radial-gradient(circle 2px at 62% 31%, rgba(180,240,255,.42) 0%, rgba(180,240,255,.42) 45%, transparent 100%),
radial-gradient(circle 3px at 69% 38%, white 0%, white 42%, transparent 100%),
radial-gradient(
circle 5px at 5% 8%,
white 0%,
white 40%,
rgba(180, 245, 255, 0.28) 72%,
transparent 100%
),
radial-gradient(
circle 3px at 15% 5%,
white 0%,
white 42%,
transparent 100%
),
radial-gradient(
circle 6px at 31% 4%,
white 0%,
white 38%,
rgba(150, 220, 255, 0.32) 70%,
transparent 100%
),
radial-gradient(
circle 3px at 47% 6%,
white 0%,
white 42%,
transparent 100%
),
radial-gradient(
circle 4px at 54% 3%,
rgba(190, 245, 255, 1) 0%,
rgba(190, 245, 255, 1) 42%,
rgba(190, 245, 255, 0.28) 70%,
transparent 100%
),
radial-gradient(
circle 5px at 71% 7%,
white 0%,
white 40%,
rgba(180, 245, 255, 0.26) 70%,
transparent 100%
),
radial-gradient(
circle 3px at 87% 9%,
white 0%,
white 42%,
transparent 100%
),
radial-gradient(
circle 4px at 94% 5%,
rgba(190, 245, 255, 1) 0%,
rgba(190, 245, 255, 1) 42%,
transparent 100%
),
radial-gradient(
circle 3px at 18% 16%,
white 0%,
white 42%,
transparent 100%
),
radial-gradient(
circle 4px at 57% 17%,
rgba(180, 240, 255, 1) 0%,
rgba(180, 240, 255, 1) 42%,
rgba(180, 240, 255, 0.26) 70%,
transparent 100%
),
radial-gradient(
circle 3px at 75% 15%,
white 0%,
white 42%,
transparent 100%
),
radial-gradient(
circle 2px at 9% 22%,
rgba(255, 255, 255, 0.65) 0%,
rgba(255, 255, 255, 0.65) 45%,
transparent 100%
),
radial-gradient(
circle 2px at 40% 25%,
rgba(255, 255, 255, 0.55) 0%,
rgba(255, 255, 255, 0.55) 45%,
transparent 100%
),
radial-gradient(
circle 2px at 66% 23%,
rgba(255, 255, 255, 0.55) 0%,
rgba(255, 255, 255, 0.55) 45%,
transparent 100%
),
radial-gradient(
circle 2px at 83% 20%,
rgba(160, 225, 255, 0.68) 0%,
rgba(160, 225, 255, 0.68) 45%,
transparent 100%
),
radial-gradient(
circle 3px at 33% 38%,
rgba(170, 230, 255, 0.52) 0%,
rgba(170, 230, 255, 0.52) 42%,
transparent 100%
),
radial-gradient(
circle 2px at 62% 31%,
rgba(180, 240, 255, 0.42) 0%,
rgba(180, 240, 255, 0.42) 45%,
transparent 100%
),
radial-gradient(
circle 3px at 69% 38%,
white 0%,
white 42%,
transparent 100%
),
/* Ambient glow + horizon line */
radial-gradient(ellipse 70% 55% at 50% 48%, rgba(0,120,200,.18) 0%, transparent 68%),
radial-gradient(ellipse 100% 4% at 50% 50%, rgba(0,245,255,.22) 0%, transparent 100%);
radial-gradient(
ellipse 70% 55% at 50% 48%,
rgba(0, 120, 200, 0.18) 0%,
transparent 68%
),
radial-gradient(
ellipse 100% 4% at 50% 50%,
rgba(0, 245, 255, 0.22) 0%,
transparent 100%
);
pointer-events: none;
z-index: 0;
}
@@ -86,13 +197,30 @@
height: 62%;
bottom: -8%;
background-image:
linear-gradient(rgba(0,245,255,.28) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,255,.16) 1px, transparent 1px);
linear-gradient(
rgba(0, 245, 255, 0.28) 1px,
transparent 1px
),
linear-gradient(
90deg,
rgba(0, 245, 255, 0.16) 1px,
transparent 1px
);
background-size: 80px 40px;
transform: perspective(520px) rotateX(75deg);
transform-origin: center 0%;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.55) 22%, black 50%);
mask-image: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,.55) 22%, black 50%);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
rgba(0, 0, 0, 0.55) 22%,
black 50%
);
mask-image: linear-gradient(
to bottom,
transparent 0%,
rgba(0, 0, 0, 0.55) 22%,
black 50%
);
pointer-events: none;
z-index: 0;
}
@@ -111,8 +239,16 @@
}
@keyframes flyIn {
from { opacity: 0; transform: scale(0); filter: blur(6px); }
to { opacity: 1; transform: scale(1); filter: blur(0); }
from {
opacity: 0;
transform: scale(0);
filter: blur(6px);
}
to {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
}
/* ─── Logo / header ──────────────────────────────────── */
@@ -131,7 +267,7 @@
height: 44px;
border-radius: 12px;
background: var(--accent-dim);
border: 1px solid rgba(0,245,255,.3);
border: 1px solid rgba(0, 245, 255, 0.3);
color: var(--accent);
flex-shrink: 0;
}
@@ -141,7 +277,7 @@
font-weight: 700;
color: var(--accent);
letter-spacing: 1.5px;
text-shadow: 0 0 14px rgba(0,245,255,.85);
text-shadow: 0 0 14px rgba(0, 245, 255, 0.85);
}
.logo-text p {
@@ -202,22 +338,24 @@
font-family: inherit;
font-size: 13px;
outline: none;
transition: border-color .15s, box-shadow .15s;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.input-wrap input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(0,245,255,.08);
box-shadow: 0 0 0 2px rgba(0, 245, 255, 0.08);
}
.input-wrap input.is-valid {
border-color: var(--success);
box-shadow: 0 0 0 2px rgba(0,255,136,.07);
box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.07);
}
.input-wrap input.is-invalid {
border-color: var(--danger);
box-shadow: 0 0 0 2px rgba(255,59,59,.07);
box-shadow: 0 0 0 2px rgba(255, 59, 59, 0.07);
}
.eye-btn {
@@ -234,10 +372,12 @@
color: var(--text-muted);
cursor: pointer;
padding: 0;
transition: color .15s;
transition: color 0.15s;
}
.eye-btn:hover { color: var(--accent); }
.eye-btn:hover {
color: var(--accent);
}
/* ─── Match indicator ────────────────────────────────── */
.match-msg {
@@ -246,13 +386,19 @@
gap: 6px;
font-size: 11px;
min-height: 16px;
transition: opacity .2s;
transition: opacity 0.2s;
}
.match-msg.ok { color: var(--success); }
.match-msg.err { color: var(--danger); }
.match-msg.ok {
color: var(--success);
}
.match-msg.err {
color: var(--danger);
}
.match-msg svg { flex-shrink: 0; }
.match-msg svg {
flex-shrink: 0;
}
/* ─── Hint ───────────────────────────────────────────── */
.field-hint {
@@ -292,29 +438,29 @@
cursor: pointer;
letter-spacing: 0.3px;
box-shadow:
0 0 8px rgba(0,245,255,.35),
inset 0 0 8px rgba(0,245,255,.04);
0 0 8px rgba(0, 245, 255, 0.35),
inset 0 0 8px rgba(0, 245, 255, 0.04);
transition:
background .15s,
box-shadow .15s,
opacity .15s,
transform .1s;
background 0.15s,
box-shadow 0.15s,
opacity 0.15s,
transform 0.1s;
}
.save-btn:not(:disabled):hover {
background: rgba(0,245,255,.08);
background: rgba(0, 245, 255, 0.08);
box-shadow:
0 0 18px rgba(0,245,255,.55),
inset 0 0 12px rgba(0,245,255,.07);
0 0 18px rgba(0, 245, 255, 0.55),
inset 0 0 12px rgba(0, 245, 255, 0.07);
}
.save-btn:not(:disabled):active {
transform: scale(.97);
opacity: .8;
transform: scale(0.97);
opacity: 0.8;
}
.save-btn:disabled {
opacity: .3;
opacity: 0.3;
cursor: not-allowed;
box-shadow: none;
border-color: var(--border);
@@ -325,15 +471,19 @@
.spinner-sm {
width: 13px;
height: 13px;
border: 2px solid rgba(255,255,255,.25);
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin .6s linear infinite;
animation: spin 0.6s linear infinite;
display: inline-block;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ─── Invalid token state ────────────────────────────── */
.invalid-state {
@@ -347,7 +497,7 @@
.invalid-state svg {
color: var(--danger);
filter: drop-shadow(0 0 8px rgba(255,59,59,.5));
filter: drop-shadow(0 0 8px rgba(255, 59, 59, 0.5));
}
.invalid-state h2 {
@@ -371,10 +521,12 @@
color: var(--accent);
text-decoration: none;
margin-top: 4px;
transition: opacity .15s;
transition: opacity 0.15s;
}
.back-link:hover { opacity: .75; }
.back-link:hover {
opacity: 0.75;
}
/* ─── Success state ──────────────────────────────────── */
.success-state {
@@ -384,24 +536,30 @@
text-align: center;
gap: 12px;
padding: 10px 0 4px;
animation: fadeIn .35s ease both;
animation: fadeIn 0.35s ease both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.success-state svg {
color: var(--success);
filter: drop-shadow(0 0 10px rgba(0,255,136,.6));
filter: drop-shadow(0 0 10px rgba(0, 255, 136, 0.6));
}
.success-state h2 {
font-size: 15px;
font-weight: 700;
color: var(--success);
text-shadow: 0 0 12px rgba(0,255,136,.4);
text-shadow: 0 0 12px rgba(0, 255, 136, 0.4);
}
.success-state p {
@@ -424,23 +582,37 @@
background: var(--success);
border-radius: 2px;
width: 0%;
box-shadow: 0 0 6px rgba(0,255,136,.6);
box-shadow: 0 0 6px rgba(0, 255, 136, 0.6);
transition: width 1.8s linear;
}
</style>
</head>
<body>
<div class="card" id="card">
<!-- Logo -->
<div class="card-logo">
<div class="logo-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="8" width="18" height="13" rx="2"/>
<path d="M9 12h.01M15 12h.01"/>
<path d="M9 16h6"/>
<path d="M12 8V5"/>
<circle cx="12" cy="4" r="1.2" fill="currentColor" stroke="none"/>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="8" width="18" height="13" rx="2" />
<path d="M9 12h.01M15 12h.01" />
<path d="M9 16h6" />
<path d="M12 8V5" />
<circle
cx="12"
cy="4"
r="1.2"
fill="currentColor"
stroke="none"
/>
</svg>
</div>
<div class="logo-text">
@@ -450,34 +622,66 @@
</div>
<div class="card-body">
<!-- ── Invalid / expired token ─────────────────── -->
<div id="invalid-state" class="invalid-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<h2>Invalid or expired link</h2>
<p>This password reset link is missing, invalid, or has already been used. Please request a new one.</p>
<p>
This password reset link is missing, invalid, or has
already been used. Please request a new one.
</p>
<a href="https://app.derez.ai" class="back-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Back to sign in
</a>
</div>
<!-- ── Reset form ──────────────────────────────── -->
<div id="form-state" style="display:none">
<div style="display:flex;flex-direction:column;gap:16px;">
<div id="form-state" style="display: none">
<div
style="display: flex; flex-direction: column; gap: 16px"
>
<div>
<p class="section-title">Reset your password</p>
<p class="section-sub" style="margin-top:6px;">Choose a strong new password for your account.</p>
<p class="section-sub" style="margin-top: 6px">
Choose a strong new password for your account.
</p>
</div>
<form id="reset-form" onsubmit="handleSave(event)" novalidate style="display:flex;flex-direction:column;gap:14px;">
<form
id="reset-form"
onsubmit="handleSave(event)"
novalidate
style="
display: flex;
flex-direction: column;
gap: 14px;
"
>
<!-- New password -->
<div class="field">
<label for="pw">New password</label>
@@ -489,14 +693,33 @@
autocomplete="new-password"
oninput="validate()"
/>
<button type="button" class="eye-btn" onclick="toggleVis('pw', this)" title="Show / hide">
<svg id="pw-eye" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
<button
type="button"
class="eye-btn"
onclick="toggleVis('pw', this)"
title="Show / hide"
>
<svg
id="pw-eye"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
<span class="field-hint">At least 8 characters.</span>
<span class="field-hint"
>At least 8 characters.</span
>
</div>
<!-- Repeat password -->
@@ -510,55 +733,93 @@
autocomplete="new-password"
oninput="validate()"
/>
<button type="button" class="eye-btn" onclick="toggleVis('pw2', this)" title="Show / hide">
<svg id="pw2-eye" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
<button
type="button"
class="eye-btn"
onclick="toggleVis('pw2', this)"
title="Show / hide"
>
<svg
id="pw2-eye"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
<!-- Match indicator -->
<div id="match-msg" class="match-msg" style="opacity:0;"></div>
<div
id="match-msg"
class="match-msg"
style="opacity: 0"
></div>
</div>
<!-- Error banner -->
<div id="error-banner" class="error-banner"></div>
<!-- Save button -->
<button type="submit" id="save-btn" class="save-btn" disabled>
<button
type="submit"
id="save-btn"
class="save-btn"
disabled
>
Save new password
</button>
</form>
</div>
</div>
<!-- ── Success ─────────────────────────────────── -->
<div id="success-state" class="success-state">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
<svg
width="44"
height="44"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<h2>Password updated!</h2>
<p>Your new password has been saved.<br>Redirecting you to sign in&hellip;</p>
<p>
Your new password has been saved.<br />Redirecting you
to sign in&hellip;
</p>
<div class="redirect-bar-wrap">
<div class="redirect-bar" id="redirect-bar"></div>
</div>
</div>
</div>
</div>
<script>
/* ─── Constants ────────────────────────────────────── */
const RESET_URL = "https://n8n.derez.ai/webhook/c2ce0eba-eb26-405d-8a90-8d982ec30698";
const APP_URL = "https://app.derez.ai";
const RESET_URL =
"https://n8n.derez.ai/webhook/account/password/change";
const APP_URL = "https://app.derez.ai";
/* ─── Extract token from URL ─────────────────────── */
/* ─── Extract token + email from URL ──────────────── */
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
const token = params.get("token");
const email = params.get("email") || "";
/* ─── Init ───────────────────────────────────────── */
/* ─── Init ───────────────────────────────────── */
if (!token || token.length < 8) {
document.getElementById("invalid-state").style.display = "flex";
} else {
@@ -580,49 +841,57 @@
/* ─── Live validation ────────────────────────────── */
function validate() {
const pw = document.getElementById("pw").value;
const pw2 = document.getElementById("pw2").value;
const pwEl = document.getElementById("pw");
const pw = document.getElementById("pw").value;
const pw2 = document.getElementById("pw2").value;
const pwEl = document.getElementById("pw");
const pw2El = document.getElementById("pw2");
const msg = document.getElementById("match-msg");
const btn = document.getElementById("save-btn");
const msg = document.getElementById("match-msg");
const btn = document.getElementById("save-btn");
// Clear error banner on re-type
const banner = document.getElementById("error-banner");
banner.style.display = "none";
banner.textContent = "";
banner.textContent = "";
const longEnough = pw.length >= 8;
const match = pw.length > 0 && pw2.length > 0 && pw === pw2;
const mismatch = pw2.length > 0 && pw !== pw2;
const match = pw.length > 0 && pw2.length > 0 && pw === pw2;
const mismatch = pw2.length > 0 && pw !== pw2;
// Style first field
pwEl.className = pw.length === 0
? ""
: longEnough ? "is-valid" : "is-invalid";
pwEl.className =
pw.length === 0
? ""
: longEnough
? "is-valid"
: "is-invalid";
// Style second field
pw2El.className = pw2.length === 0
? ""
: match ? "is-valid" : mismatch ? "is-invalid" : "";
pw2El.className =
pw2.length === 0
? ""
: match
? "is-valid"
: mismatch
? "is-invalid"
: "";
// Match message
if (match) {
msg.style.opacity = "1";
msg.className = "match-msg ok";
msg.innerHTML =
msg.className = "match-msg ok";
msg.innerHTML =
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>' +
"Passwords match";
} else if (mismatch) {
msg.style.opacity = "1";
msg.className = "match-msg err";
msg.innerHTML =
msg.className = "match-msg err";
msg.innerHTML =
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' +
"Passwords don\u2019t match";
} else {
msg.style.opacity = "0";
msg.className = "match-msg";
msg.innerHTML = "";
msg.className = "match-msg";
msg.innerHTML = "";
}
btn.disabled = !(match && longEnough);
@@ -632,40 +901,50 @@
async function handleSave(e) {
e.preventDefault();
const password = document.getElementById("pw").value;
const btn = document.getElementById("save-btn");
const banner = document.getElementById("error-banner");
const btn = document.getElementById("save-btn");
const banner = document.getElementById("error-banner");
// Loading state
btn.disabled = true;
btn.innerHTML = '<span class="spinner-sm"></span> Saving\u2026';
btn.disabled = true;
btn.innerHTML = '<span class="spinner-sm"></span> Saving\u2026';
try {
const res = await fetch(RESET_URL, {
method: "POST",
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
body: JSON.stringify({ token, password, email }),
});
if (!res.ok) throw new Error("The reset link may have expired. Please request a new one.");
if (!res.ok)
throw new Error(
"The reset link may have expired. Please request a new one.",
);
// Success — show confirmation then redirect
document.getElementById("form-state").style.display = "none";
document.getElementById("success-state").style.display = "flex";
document.getElementById("form-state").style.display =
"none";
document.getElementById("success-state").style.display =
"flex";
// Animate progress bar then redirect
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.getElementById("redirect-bar").style.width = "100%";
document.getElementById(
"redirect-bar",
).style.width = "100%";
});
});
setTimeout(() => { window.location.href = APP_URL; }, 2000);
setTimeout(() => {
window.location.href = APP_URL;
}, 2000);
} catch (err) {
banner.textContent = err.message || "Something went wrong. Please try again.";
banner.textContent =
err.message ||
"Something went wrong. Please try again.";
banner.style.display = "flex";
btn.disabled = false;
btn.innerHTML = "Save new password";
btn.disabled = false;
btn.innerHTML = "Save new password";
}
}
</script>
+374 -13
View File
@@ -709,6 +709,11 @@ body::after {
flex-shrink: 0;
}
.agent-tab-dot--red {
background: var(--danger);
box-shadow: 0 0 0 2px rgba(224, 82, 82, 0.25);
}
.agent-tab-badge {
font-size: 10px;
font-weight: 600;
@@ -727,16 +732,16 @@ body::after {
.agent-tab-add {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px dashed var(--border);
gap: 7px;
padding: 6px 14px;
border-radius: 20px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
font-size: 18px;
line-height: 1;
font-size: 13px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
transition:
border-color 0.15s,
@@ -744,9 +749,9 @@ body::after {
background 0.15s;
}
.agent-tab-add:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
border-color: rgba(79, 142, 247, 0.4);
color: var(--text);
background: var(--bg-hover);
}
.tabs-loading,
@@ -1438,6 +1443,321 @@ body::after {
gap: 10px;
}
/* ─── Account Modal ────────────────────────────────────────────────── */
.account-modal {
max-width: 760px;
width: 100%;
}
.account-modal .modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
}
.account-modal .modal-header .auth-switch {
flex: 1;
margin: 0;
background: transparent;
border: none;
padding: 0;
display: flex;
gap: 0;
}
.account-modal .modal-header .auth-switch-btn {
white-space: nowrap;
padding: 8px 12px;
}
.account-modal .modal-header .auth-switch-btn.active {
background: var(--bg-inner);
border: 1px solid var(--border);
box-shadow: none;
}
.modal-close-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
transition: color 0.15s;
}
.modal-close-btn:hover {
color: var(--text);
}
.account-modal-body {
display: flex;
height: 420px;
}
.account-pane {
display: flex;
width: 100%;
}
.account-col {
display: flex;
flex-direction: column;
}
.account-col--settings {
width: 260px;
flex-shrink: 0;
padding: 18px 20px;
}
.account-col-divider {
width: 1px;
background: var(--border);
flex-shrink: 0;
}
.account-col--invoices {
flex: 1;
padding: 18px 20px;
min-width: 0;
overflow: hidden;
}
.acct-section {
padding: 4px 0 10px;
}
.acct-divider {
height: 1px;
background: var(--border);
margin: 6px 0 14px;
}
.acct-field-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 4px;
}
.acct-field-val {
font-size: 13px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acct-affiliate-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
.acct-affiliate-code {
font-size: 13px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.06em;
font-family: "Courier New", monospace;
}
.acct-invoices-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
gap: 10px;
}
.acct-invoices-title {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.acct-invoices-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: flex-end;
}
.invoice-filter-input {
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px 8px;
font-size: 11px;
color: var(--text);
width: 140px;
outline: none;
transition: border-color 0.15s;
}
.invoice-filter-input:focus {
border-color: var(--accent);
}
.invoice-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 360px;
overflow-y: auto;
}
.invoice-row {
display: grid;
grid-template-columns: 100px 1fr 1fr 80px 60px;
gap: 10px;
align-items: center;
padding: 7px 10px;
border-radius: var(--radius);
font-size: 12px;
}
.invoice-row--head {
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
background: none;
padding-bottom: 4px;
margin-bottom: 2px;
border-bottom: 1px solid var(--border);
}
.invoice-row:not(.invoice-row--head) {
background: var(--bg-inner);
border: 1px solid var(--border);
color: var(--text);
}
.invoice-row:not(.invoice-row--head):hover {
border-color: var(--border-hi);
}
.invoice-date {
color: var(--text-muted);
white-space: nowrap;
}
.invoice-uuid {
color: var(--accent);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invoice-product {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invoice-price {
text-align: right;
font-weight: 600;
color: var(--success);
white-space: nowrap;
}
.invoice-dl {
text-align: center;
}
.invoice-dl-link {
color: var(--accent);
text-decoration: none;
font-size: 11px;
transition: opacity 0.15s;
}
.invoice-dl-link:hover {
opacity: 0.7;
}
.invoice-dl-link--disabled {
color: var(--text-muted);
cursor: default;
}
/* ─── Credits Modal ────────────────────────────────────────── */
.credits-key-label {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 14px;
}
.credits-key-label span {
color: var(--accent);
font-weight: 600;
}
.credits-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 14px;
}
.credits-option {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
background: var(--bg-inner);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s;
user-select: none;
}
.credits-option:has(input:checked) {
border-color: var(--accent);
background: var(--accent-dim);
}
.credits-option input[type="radio"] {
accent-color: var(--accent);
width: 15px;
height: 15px;
flex-shrink: 0;
cursor: pointer;
}
.credits-option-label {
font-size: 15px;
font-weight: 600;
color: var(--text);
letter-spacing: 0.02em;
}
.credits-fee-note {
font-size: 12px;
color: var(--text-muted);
line-height: 1.55;
background: var(--bg-inner);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
margin: 0;
}
/* ─── Utility ───────────────────────────────────────────────── */
.text-muted {
color: var(--text-muted);
@@ -1556,8 +1876,8 @@ body::after {
}
.chat-msg-user {
align-self: flex-end;
background: var(--accent);
color: #fff;
background: var(--bg-hover);
color: var(--text);
border-bottom-right-radius: 3px;
}
.chat-msg-bot {
@@ -1711,6 +2031,14 @@ body.auth-layout .auth-pane:last-child {
.hire-link:hover {
color: var(--accent);
}
.hire-note {
margin: auto 0 0;
font-size: 12px;
color: var(--text-muted);
text-align: center;
line-height: 1.5;
}
body.auth-layout .auth-forms-divider {
width: 1px;
align-self: stretch;
@@ -1881,11 +2209,12 @@ body.auth-layout #signin-btn {
.contract-extend-link {
display: inline-block;
margin-top: 5px;
margin-left: auto;
font-size: 12px;
font-weight: 600;
color: var(--danger);
text-decoration: none;
flex-shrink: 0;
}
.contract-extend-link:hover {
@@ -1922,6 +2251,38 @@ body.auth-layout #signin-btn {
border-color: var(--danger);
}
/* ─── Affiliate Area Cards ───────────────────────────────────── */
.affiliate-areas-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.affiliate-area-card {
background: var(--bg-inner);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
}
.affiliate-area-card .form-group input {
font-size: 12px;
padding: 5px 8px;
}
.affiliate-area-name {
font-size: 11px;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
/* ─── Wizard (Integrations setup) ─────────────────────────────── */
#wizard-card {
position: absolute;