Add Connect Desk source with gitignore for builds and secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
main
sheheryarsoomro12 2026-08-27 21:16:01 +05:00
commit 373d15ae03
13 changed files with 6478 additions and 0 deletions

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# Dependencies
node_modules/
# Packaged installers and unpacked Electron builds
dist/
out/
release/
# Logs and debug
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS junk
.DS_Store
Thumbs.db
Desktop.ini
# Editor
.idea/
.vscode/
*.swp
*.swo
*~
# Env and local secrets (never commit credentials)
.env
.env.*
credentials.json
accounts.json
*.pem
*.p12
*.key
# Electron / native
.electron-gyp
.cache/
*.tsbuildinfo

51
DISTRIBUTION.md Normal file
View File

@ -0,0 +1,51 @@
# Distributing Connect Desk
The installers live in `dist/` after a build:
| File | Platform | Notes |
|---|---|---|
| `Connect Desk Setup 1.9.0.exe` | Windows 10/11 (x64) | NSIS installer — runs on any normal Intel/AMD PC, and on Windows-on-ARM via emulation |
| `Connect Desk-1.9.0-arm64.dmg` | macOS (Apple Silicon) | Drag-to-Applications DMG |
| `Connect Desk-1.9.0.dmg` | macOS (Intel) | Drag-to-Applications DMG |
Rebuild any time:
```sh
npm run dist # Mac arm64 + Intel DMGs, and Windows x64 NSIS
npm run dist:mac # both Mac DMGs
npm run dist:win # Windows x64 installer (always --x64; this Mac is Apple Silicon)
```
## What recipients do
**Windows:** run `Connect Desk Setup 1.9.0.exe`, pick an install folder, finish. A desktop and
Start-menu shortcut named "Connect Desk" are created; the app lives in the chosen folder as
`Connect Desk.exe`.
**macOS:** open the DMG, drag **Connect Desk** into **Applications**, launch from there.
Each installation starts empty: the recipient adds their accounts with **+ Add account** and signs in
with their own Connect credentials. Nothing from your machine — no accounts, no sessions, no
cookies — is inside the installer; only the app code ships.
## The unsigned-app warnings (tell recipients this up front)
These builds are not code-signed — there is no paid Apple Developer ID or Windows signing
certificate behind them — so both OSes show a one-time scare prompt:
- **Windows SmartScreen**: "Windows protected your PC" → click **More info****Run anyway**.
- **macOS Gatekeeper**: "cannot be opened because the developer cannot be verified" →
**right-click the app → Open → Open**. Once is enough.
This is expected for unsigned software and does not indicate a problem with the app. If you later
want prompt-free installs, that requires purchasing certificates: an Apple Developer ID
(~$99/year, plus notarization) and a Windows code-signing certificate — the config is ready for
both; only the certificates are missing.
## Architecture cheat sheet
- The build machine's CPU decides the default target. This Mac is Apple Silicon, so a plain
`--win nsis` build silently produced an **arm64** exe that ordinary PCs cannot run — always pass
`--x64` for Windows.
- For an Intel-Mac DMG: `npx electron-builder --mac dmg --x64`. For one DMG that runs on both:
`--universal` (roughly doubles the file size).

119
README.md Normal file
View File

@ -0,0 +1,119 @@
# Connect Desk
One window. Every Amazon Connect login signed in at the same time.
```sh
npm start
```
## The problem it solves
All your accounts live on the **same** Connect domain and differ only by login. A browser profile
holds exactly one session per domain, so signing in as account B silently evicts account A — which is
why you ended up with a separate Chrome profile per brand.
Connect Desk gives every account its **own session partition** (its own cookie jar). Ten accounts stay
signed in simultaneously, side by side, in one window. Sessions persist across restarts: sign in once
per account and stay in.
## Why it isn't a web page
Connect sends `Content-Security-Policy: frame-ancestors 'self'`, which forbids any external site from
embedding the CCP in an iframe. Only the AWS account that *owns* the instance can allowlist an origin,
and these instances are owned by Amazon, not by you — so a browser-based dashboard is permanently
blocked.
`frame-ancestors` restricts documents embedded **in a frame**. Here each account is a **top-level
view**, not a frame, so the rule doesn't apply. Verified: the CCP loads in each partition with zero CSP
errors, and no AWS permission of any kind is required.
## The two instances
Accounts live on one of two Amazon Connect instances (separate directories, separate logins):
| Marketplaces | Instance |
|---|---|
| US, CA | `amazon-product-support-a67d8h.my.connect.aws/agent-app-v2` |
| UK, DE, FR, IT, ES, NL, BE, SE | `amazon-product-support-q42kxm.my.connect.aws/agent-app-v2` |
Each account is assigned to an instance when you add it, shown as a **US** / **EU** chip on its row,
and changeable from the right-click menu. The URLs are stored in `accounts.json` under `instances`
if they ever need editing.
## Using it
- **Sidebar** lists your accounts. Click one to switch; the others keep running in the background and
keep receiving chats (background throttling is disabled for exactly this reason).
- **Incoming chat** is impossible to miss: a full-sidebar popup, a native OS notification (Mac and
Windows), a beep, taskbar/Dock flash, and the app switches to the ringing account. Click the popup
or the OS notification to jump there. This still works if you were looking at a different account.
- **+ Add account** first asks which instance (US/CA or UK/EU) the account belongs to, creates a new
isolated session, and drops you straight into naming it. Names also fill in automatically from the
signed-in agent; a manual rename always wins.
- **Right-click an account** for Rename, Set Available, Set Offline, instance selection, Reload, and
Remove (native OS menu).
- **Right-click in a chat** for spelling suggestions plus Cut / Copy / Paste. Spellcheck is on for
Connect composers (US + UK English).
- **All Available / All Offline** flip every signed-in account's status at once; a toast summarizes
which accounts changed and which need sign-in.
- **Sign in all / Sign out all** manage sessions in bulk. Sign-out ends each session server-side,
wipes the account's cookies, and **pauses auto-login for that account** (otherwise saved sign-ins
would immediately log it back in) — the dot goes grey with a "signed out" chip. Sign in all (or
per-account right-click → Sign in) resumes auto-login. This state survives app restarts.
## Credentials
By default none are stored — each account view shows Connect's own login page and the session cookie
stays inside that account's partition.
**Saved sign-ins (optional, off until you use it):** the "Saved sign-ins…" panel stores a username +
password per account so accounts log themselves in — at startup and again whenever a session expires.
Each row has **Save** (store this account's credentials) and **Sign in** (save, then log this one
account in immediately); the footer has **Save all**, **Sign in all**, and **Close**. Saving never
signs anything in on its own — the two are separate deliberate actions. "Sign in all" works through
the accounts **one at a time** with a gap between each, so a dozen simultaneous logins don't hit
Amazon's auth endpoint at once.
How it's protected:
- Passwords are encrypted with the OS keychain-backed key (`safeStorage`: macOS Keychain / Windows
DPAPI) and stored as ciphertext in `credentials.json`. Never plaintext; `accounts.json` never
contains secrets; the file cannot be decrypted on another computer or by another OS user.
- Credentials are released to an account's view **only** when that view is on the account's real
Amazon login host (`<alias>.my.connect.aws` / `<alias>.awsapps.com`) — enforced in the main
process, so no other page can request them.
- At most 2 automatic attempts per account per 5 minutes, so a wrong password can't be hammered into
an account lockout.
- "forget" in the panel deletes an account's saved sign-in; removing an account does too.
- If the form can't be filled, **nothing is submitted** and a toast says why — an empty submit would
burn an auth attempt and push the account toward a lockout.
The sign-in page is a GWT app whose username box is `<input type="username" id="wdc_username">`
`username` is not a valid HTML input type, so the field is matched by name/id, never by type. The
page also carries a hidden `#wdc_organization` decoy and a hidden `#wdc_mfa` second password field;
both are deliberately skipped. If Amazon ever enables MFA on an account, auto sign-in cannot complete
it and you'll need to sign in manually.
The honest trade-off: anyone using your unlocked computer session can start the app and be signed in
— same as any password manager without a master password. Lock your machine.
## Known limits
- **One login per account, once.** AWS confirms there is no cross-account SSO without SAML federation,
which only the instance owner can configure. This app removes the *browser juggling*, not the logins.
- **Chat detection is best-effort.** It hooks Amazon Connect Streams (`window.connect`) in the CCP
iframe inside Agent Workspace, and falls back to scanning the page text if Streams never appears.
The fallback can break if Amazon restyles the CCP. Switching accounts and the sessions themselves
are unaffected either way.
- Ten-plus live CCPs is genuinely heavy. If it drags, remove accounts you aren't covering that day.
## Layout
| File | Role |
|---|---|
| `main.js` | Window, one partitioned `WebContentsView` per account, alerts, notifications |
| `probe.js` | Main-world Streams probe (injected into the CCP page and its iframes) |
| `preload.js` | Injected per account — relays contacts, status commands, login autofill |
| `ui-preload.js` | IPC bridge for the sidebar |
| `ui/` | Sidebar: account list, alerts, controls |

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

1039
main.js Normal file

File diff suppressed because it is too large Load Diff

3595
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "connect-desk",
"version": "1.9.0",
"description": "One window for every Amazon Connect account — simultaneous sign-ins, unified chat alerts, bulk status control.",
"main": "main.js",
"scripts": {
"start": "electron .",
"dist:mac": "electron-builder --mac dmg --arm64 --x64",
"dist:win": "electron-builder --win nsis --x64",
"dist": "npm run dist:mac && npm run dist:win"
},
"author": "AI Team",
"license": "UNLICENSED",
"private": true,
"devDependencies": {
"electron": "^43.2.0",
"electron-builder": "^26.0.12"
},
"build": {
"appId": "com.aiteam.connectdesk",
"productName": "Connect Desk",
"directories": {
"buildResources": "build",
"output": "dist"
},
"files": [
"main.js",
"preload.js",
"probe.js",
"ui-preload.js",
"ui/**"
],
"mac": {
"category": "public.app-category.business",
"target": [
"dmg"
]
},
"win": {
"target": [
"nsis"
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Connect Desk"
}
}
}

193
preload.js Normal file
View File

@ -0,0 +1,193 @@
// Injected into every account view (top page and CCP iframes). Three jobs:
// 1. Detect the signed-in agent (name + state) and report it to main.
// 2. Detect contacts arriving/leaving for the unified alert strip.
// 3. Execute status commands ("go Available"/"go Offline") inside the CCP.
//
// The probe must run in the PAGE's main world to see `window.connect`. It is
// injected with webFrame.executeJavaScript — NOT a <script> tag, because the
// CCP's CSP (script-src 'self', no unsafe-inline) silently blocks injected
// tags. webFrame.executeJavaScript is not subject to page CSP.
//
// Agent Workspace keeps Streams in a CCP iframe, so this preload also runs in
// subframes (nodeIntegrationInSubFrames). Autofill stays top-frame only.
const { ipcRenderer, webFrame } = require("electron");
const { probeScript } = require("./probe");
function inject() {
webFrame.executeJavaScript(probeScript).catch(() => {});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", inject, { once: true });
} else {
inject();
}
// Relay page -> main. Same-origin iframe probes post to window.top; only the
// top frame forwards those. Cross-origin iframes can't reach top, so they send
// IPC themselves.
window.addEventListener("message", (e) => {
const d = e.data;
if (!d || d.__cdmsg !== true) return;
if (
!["agent", "detection", "state", "auth", "status-result", "incoming", "gone", "reply", "accepted"].includes(
d.type
)
) {
return;
}
try {
if (window !== window.top) {
try {
void window.top.location.href; // throws if this iframe is cross-origin
return; // same-origin: the top frame's listener will relay
} catch {
ipcRenderer.send("contact", d);
return;
}
}
} catch {}
ipcRenderer.send("contact", d);
});
// Relay main -> page (status commands). Forward into same-origin iframes so a
// top-level send still reaches the CCP frame that owns window.connect.
ipcRenderer.on("cd-set-status", (_e, kind) => {
const msg = { __cdcmd: true, cmd: "set-status", kind };
window.postMessage(msg, "*");
try {
if (window.top !== window) return;
for (const f of document.querySelectorAll("iframe")) {
try {
f.contentWindow.postMessage(msg, "*");
} catch (err) {}
}
} catch (err) {}
});
// ---------- saved sign-in autofill ----------
// Runs in the preload's isolated world (direct DOM access, invisible to the
// page's scripts). Credentials come from the main process, which releases them
// only when this view's URL is the account's real Amazon login host, at most
// twice per 5 minutes. One attempt per page load. Iframes skip this entirely.
(function autofill() {
try {
if (window !== window.top) return;
} catch {
return;
}
let done = false;
let busy = false;
// Written against the real Amazon Connect sign-in markup (a GWT app):
// <input type="text" id="wdc_organization"> visibility:hidden (decoy)
// <input type="username" id="wdc_username"> <-- NOT a valid HTML
// type; filtering for
// text/email misses it
// <input type="password" id="wdc_password">
// <input type="password" id="wdc_mfa"> display:none
// <button id="wdc_login_button">Sign In</button>
const cssHidden = (el) => {
const c = getComputedStyle(el);
return c.display === "none" || c.visibility === "hidden";
};
const idText = (el) =>
`${el.name || ""} ${el.id || ""} ${el.getAttribute("placeholder") || ""} ${
el.getAttribute("aria-label") || ""
}`;
// GWT and React both ignore a plain .value write: go through the native
// setter, then fire the events either framework might be listening for.
const setValue = (el, value) => {
if (!el) return;
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
setter.call(el, value);
for (const type of ["input", "change", "keyup", "blur"]) {
el.dispatchEvent(new Event(type, { bubbles: true }));
}
};
// Skip display:none password fields — #wdc_mfa is one, and grabbing it
// instead of the real box would silently fill nothing.
const findPassword = () =>
[...document.querySelectorAll('input[type="password"]')].find((el) => !cssHidden(el)) || null;
// Identify the username box by NAME, not by type — its type is the
// non-standard "username". Exclude by what a field can't be, then prefer one
// named like a username, then fall back to the nearest box above the
// password field.
const findUsername = (pass) => {
const NON_TEXT = new Set([
"password", "checkbox", "radio", "hidden", "submit", "button",
"file", "image", "reset", "range", "color",
]);
const cands = [...document.querySelectorAll("input")].filter(
(el) => !NON_TEXT.has((el.getAttribute("type") || "text").toLowerCase())
);
const named = cands.filter((el) => /user|email|login/i.test(idText(el)));
return (
named.find((el) => !cssHidden(el)) || // #wdc_username
named[0] ||
cands
.filter((el) => !cssHidden(el) && el.compareDocumentPosition(pass) & 4)
.pop() ||
cands.find((el) => !cssHidden(el)) ||
null
);
};
const tryFill = async () => {
if (done || busy) return done;
const pass = findPassword();
if (!pass) return false;
const text = (document.body && document.body.innerText) || "";
if (!/sign\s?in|log\s?in/i.test(text)) return false; // password box, but not a login page
busy = true;
const cred = await ipcRenderer.invoke("login:request").catch(() => null);
busy = false;
if (done) return true;
if (!cred) {
done = true; // nothing saved (or rate-limited) — stop asking this load
return true;
}
done = true;
const user = findUsername(pass);
setValue(user, cred.username);
setValue(pass, cred.password);
// Verify before submitting. A submit with an empty username burns an auth
// attempt and moves the account toward a lockout, so report instead.
setTimeout(() => {
const userOk = user && user.value === cred.username;
const passOk = pass.value === cred.password;
if (!userOk || !passOk) {
ipcRenderer.send("contact", {
type: "autofill-failed",
reason: !userOk ? "could not fill the username field" : "could not fill the password field",
});
return;
}
const btn =
document.getElementById("wdc_login_button") ||
document.querySelector('button[type="submit"], input[type="submit"]') ||
[...document.querySelectorAll("button")].find((b) =>
/sign\s?in|log\s?in/i.test(b.textContent)
);
if (btn) btn.click();
else ipcRenderer.send("contact", { type: "autofill-failed", reason: "no Sign In button found" });
}, 400);
return true;
};
// Login forms often render late — poll for a while, then give up quietly.
const timer = setInterval(async () => {
if (await tryFill()) clearInterval(timer);
}, 900);
setTimeout(() => clearInterval(timer), 45000);
})();

280
probe.js Normal file
View File

@ -0,0 +1,280 @@
// Main-world probe injected into every account document (top page and CCP
// iframes). Serialized with toString(), so this function must be fully
// self-contained — no requires, no closed-over values.
//
// Jobs: detect the signed-in agent, detect contacts arriving/leaving, run
// Available/Offline commands, and force spellcheck on chat composers.
function mainWorldProbe() {
if (window.__cdProbe) return;
window.__cdProbe = true;
const post = function (m) {
const payload = Object.assign({ __cdmsg: true }, m);
window.postMessage(payload, "*");
try {
if (window.top && window.top !== window) window.top.postMessage(payload, "*");
} catch (e) {}
};
// ---------- signed-in agent: name + auth ----------
let namePosted = "";
// Login-form furniture is not a name — "Remember username" once got saved as
// an agent name because the username field's class matched the selector.
const NOT_A_NAME = /remember|username|password|sign\s?in|log\s?in|forgot|welcome/i;
const postName = function (n) {
n = (n || "").trim();
if (!n || NOT_A_NAME.test(n)) return;
if (n !== namePosted) {
namePosted = n;
post({ type: "agent", name: n });
}
};
const looksLikeLogin = function () {
const t = (document.body && document.body.innerText) || "";
return /Please log in|Forgot Password/i.test(t) && t.length < 900;
};
function scrapeName() {
try {
const text = (document.body && document.body.innerText) || "";
if (looksLikeLogin()) return;
const patterns = [
/Welcome\s+([^\s\n][^\n]{0,60})/,
/Signed in as\s+([^\s\n][^\n]{0,60})/i,
/Logged in as\s+([^\s\n][^\n]{0,60})/i,
];
for (let i = 0; i < patterns.length; i++) {
const m = text.match(patterns[i]);
if (m) return postName(m[1]);
}
const el = document.querySelector(
'[data-testid*="user" i],[class*="userName" i],[class*="agentName" i]'
);
if (el && el.textContent) postName(el.textContent);
} catch (e) {}
}
// ---------- agent status control (bulk Available/Offline) ----------
function doSetStatus(kind) {
const c = window.connect;
const fail = function (error) {
post({ type: "status-result", kind: kind, ok: false, error: error });
};
if (!c || !c.Agent) {
// Connect lives in the CCP iframe. Frames without Streams stay quiet so
// a top-frame "not signed in" doesn't beat a successful iframe reply.
// Login pages (top, no iframes, no connect) still fail fast.
try {
if (window !== window.top) return;
if (document.querySelectorAll("iframe").length) return;
} catch (e) {
return;
}
return fail("not signed in");
}
let agent;
try {
agent = new c.Agent();
} catch (e) {
return fail("not signed in");
}
let target;
try {
const wanted = kind === "offline" ? c.AgentStateType.OFFLINE : c.AgentStateType.ROUTABLE;
target = (agent.getAgentStates() || []).find(function (s) {
return s.type === wanted;
});
} catch (e) {
return fail("states unavailable");
}
if (!target) return fail("no matching state");
try {
agent.setState(
target,
{
success: function () {
post({ type: "status-result", kind: kind, ok: true, state: target.name });
},
failure: function () {
fail("rejected by Connect");
},
},
{ enqueueNextState: true }
);
} catch (e) {
fail((e && e.message) || "error");
}
}
window.addEventListener("message", function (e) {
const d = e.data;
if (!d || d.__cdcmd !== true) return;
if (d.cmd === "set-status") doSetStatus(d.kind);
});
// ---------- Streams hookup ----------
let streams = false;
const seenContacts = {};
function handleContact(contact) {
let id;
try {
id = contact.getContactId();
} catch (e) {
return;
}
if (!id || seenContacts[id]) return;
seenContacts[id] = true;
let q = "";
try {
q = (contact.getQueue() && contact.getQueue().name) || "";
} catch (e) {}
const gone = function () {
delete seenContacts[id];
post({ type: "gone", contactId: id });
};
post({ type: "incoming", contactId: id, queue: q });
["onConnected", "onEnded", "onMissed", "onDestroy", "onAccepted"].forEach(function (ev) {
try {
contact[ev] && contact[ev](gone);
} catch (e) {}
});
}
function tryStreams() {
const c = window.connect;
if (!c || typeof c.contact !== "function" || typeof c.agent !== "function") return false;
try {
c.contact(function (contact) {
handleContact(contact);
});
} catch (e) {
return false;
}
streams = true;
try {
c.agent(function (agent) {
post({ type: "auth", signedIn: true });
try {
postName(agent.getName && agent.getName());
} catch (e) {}
let lastState = "";
const sendState = function () {
try {
const s = agent.getState();
const key = ((s && s.name) || "") + "|" + ((s && s.type) || "");
if (key === lastState) return;
lastState = key;
post({ type: "state", stateName: (s && s.name) || "", stateType: (s && s.type) || "" });
} catch (e) {}
};
sendState();
try {
agent.onStateChange(sendState);
} catch (e) {}
try {
agent.onRefresh(sendState);
} catch (e) {}
// Catch a chat that was already ringing before we hooked contact().
try {
const contacts = agent.getContacts && agent.getContacts();
if (contacts && contacts.length) {
for (let i = 0; i < contacts.length; i++) handleContact(contacts[i]);
}
} catch (e) {}
});
} catch (e) {}
post({ type: "detection", mode: "streams" });
return true;
}
setInterval(function () {
if (!streams) tryStreams();
}, 1000);
tryStreams();
// Name + login-page detection loop. Keeps going until the name lands, because
// sign-in can happen long after the page first loads.
let loginPosted = false;
const nameTimer = setInterval(function () {
if (!streams) {
if (looksLikeLogin()) {
if (!loginPosted) {
loginPosted = true;
post({ type: "auth", signedIn: false });
}
} else {
loginPosted = false;
}
}
scrapeName();
if (namePosted && streams) clearInterval(nameTimer);
}, 2500);
scrapeName();
// DOM incoming-chat watch — always on, even if Streams hooked.
// Agent Workspace shows "Incoming chat" / "Accept chat" in the page even
// when connect.contact() never fires. Skipping this once streams=true is
// why the sidebar stayed on "No waiting chats" while CCP was ringing.
const INCOMING_RE =
/incoming chat|incoming contact|accept chat|reject chat|customer is waiting|new chat|chat request/i;
let domSeen = false;
const scanIncoming = function () {
const hit = INCOMING_RE.test((document.body && document.body.innerText) || "");
if (hit && !domSeen) {
domSeen = true;
post({ type: "incoming", contactId: "dom-incoming", queue: "" });
} else if (!hit && domSeen) {
domSeen = false;
post({ type: "gone", contactId: "dom-incoming" });
}
};
try {
new MutationObserver(scanIncoming).observe(document.documentElement, {
childList: true,
subtree: true,
characterData: true,
});
} catch (e) {}
setInterval(scanIncoming, 1500);
scanIncoming();
// Extra Streams-only fallback if the SDK never appears at all (login page).
setTimeout(function () {
if (streams) return;
post({ type: "detection", mode: "dom" });
}, 20000);
// Connect's composer often sets spellcheck="false". Force it back on so
// Chromium's checker and our context-menu suggestions can run.
document.addEventListener(
"focusin",
function (e) {
const t = e.target;
if (!t) return;
const tag = (t.tagName || "").toUpperCase();
const type = (t.type || "").toLowerCase();
if (type === "password" || type === "hidden") return;
if (t.isContentEditable || tag === "TEXTAREA" || tag === "INPUT") {
try {
t.spellcheck = true;
t.setAttribute("spellcheck", "true");
} catch (err) {}
}
},
true
);
}
module.exports = {
mainWorldProbe,
probeScript: `(${mainWorldProbe.toString()})();`,
};

30
ui-preload.js Normal file
View File

@ -0,0 +1,30 @@
// Bridge for the sidebar. The sidebar never touches Node directly.
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("api", {
onState: (cb) => ipcRenderer.on("state", (_e, s) => cb(s)),
onError: (cb) => ipcRenderer.on("account:error", (_e, e) => cb(e)),
getState: () => ipcRenderer.invoke("get-state"),
activate: (id) => ipcRenderer.invoke("activate", id),
reload: (id) => ipcRenderer.invoke("reload", id),
addAccount: (instanceId) => ipcRenderer.invoke("add-account", instanceId),
removeAccount: (id) => ipcRenderer.invoke("remove-account", id),
renameAccount: (id, label) => ipcRenderer.invoke("rename-account", { id, label }),
setAccountInstance: (id, instanceId) =>
ipcRenderer.invoke("set-account-instance", { id, instanceId }),
focusAlert: (accountId) => ipcRenderer.invoke("focus-alert", accountId),
signIn: (id) => ipcRenderer.invoke("signin", id),
signOut: (id) => ipcRenderer.invoke("signout", id),
signInAll: () => ipcRenderer.invoke("signin-all"),
signOutAll: () => ipcRenderer.invoke("signout-all"),
credsList: () => ipcRenderer.invoke("creds:list"),
credsSave: (entries, opts) => ipcRenderer.invoke("creds:save", entries, opts),
setStatus: (id, kind) => ipcRenderer.invoke("set-status", { id, kind }),
setStatusAll: (kind) => ipcRenderer.invoke("set-status-all", kind),
onToast: (cb) => ipcRenderer.on("toast", (_e, t) => cb(t)),
onMenuAction: (cb) => ipcRenderer.on("menu:action", (_e, a) => cb(a)),
showAccountMenu: (id) => ipcRenderer.send("show-account-menu", id),
showInstanceMenu: () => ipcRenderer.invoke("show-instance-menu"),
});

58
ui/index.html Normal file
View File

@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header>
<h1>Connect Desk</h1>
<div id="instance" title="The Connect instances accounts can use"></div>
</header>
<section id="alerts">
<div class="empty">No waiting chats</div>
</section>
<nav id="accounts"></nav>
<footer>
<div id="bulk-label">All accounts</div>
<div id="bulk" role="group" aria-label="Set status for all accounts">
<button id="all-available" type="button" title="Set every signed-in account to Available">
<span class="bdot av"></span>Available
</button>
<button id="all-offline" type="button" title="Set every signed-in account to Offline">
<span class="bdot off"></span>Offline
</button>
</div>
<div id="bulk2" role="group" aria-label="Sessions for all accounts">
<button id="all-signin" type="button" title="Sign every account in with its saved credentials">
Sign in all
</button>
<button id="all-signout" type="button" title="Sign every account out and pause auto-login">
Sign out all
</button>
</div>
<button id="add" type="button">+ Add account</button>
<button id="signins" type="button">Saved sign-ins…</button>
<div id="hint">
Right-click an account for rename, status, and instance. Adding an account first asks which
instance (US/CA or UK/EU) it belongs to.
</div>
<div id="credit">Developed by AI Team</div>
</footer>
<div id="incoming-pop" hidden>
<div class="incoming-pop-card">
<div class="incoming-pop-kicker">Incoming chat</div>
<div id="incoming-pop-name"></div>
<button type="button" id="incoming-pop-go">Open account</button>
</div>
</div>
<div id="toast" hidden></div>
<div id="creds" hidden></div>
<script src="ui.js"></script>
</body>
</html>

586
ui/styles.css Normal file
View File

@ -0,0 +1,586 @@
:root {
--bg: #14171a;
--surface: #1b1f24;
--surface-2: #222831;
--line: #2c333d;
--text: #e6edf3;
--muted: #8b98a5;
--accent: #4493f8;
--ok: #3fb950;
--warn: #d29922;
--err: #f85149;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
height: 100vh;
display: grid;
grid-template-rows: auto auto 1fr auto;
/* One column, hard-capped at the body's width. Without this the implicit
column sizes to max-content the nowrap instance URL once blew it out to
~390px inside a 268px view, pushing the right side of every row and the
second footer button past the visible edge. */
grid-template-columns: minmax(0, 1fr);
background: var(--bg);
color: var(--text);
font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
overflow-y: hidden;
/* clip, not hidden: hidden containers can still be scrolled programmatically
(e.g. by focusing the rename input), which shoved the whole sidebar
sideways. clip forbids scrolling entirely. */
overflow-x: clip;
}
header,
#alerts,
#accounts,
footer {
overflow-x: clip;
min-width: 0; /* grid items may never dictate the column width */
}
header {
padding: 14px 14px 10px;
border-bottom: 1px solid var(--line);
-webkit-app-region: drag;
}
h1 {
margin: 0 0 3px;
font-size: 13px;
font-weight: 600;
}
#instance {
font: 10px ui-monospace, monospace;
color: var(--muted);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
-webkit-app-region: no-drag;
}
#instance:hover {
color: var(--accent);
}
/* --- unified alerts --- */
#alerts {
padding: 8px;
border-bottom: 1px solid var(--line);
background: var(--surface);
max-height: 190px;
overflow-y: auto;
}
#alerts .empty {
color: var(--muted);
font-size: 11px;
padding: 2px 4px;
}
.alert {
padding: 7px 9px;
margin-bottom: 5px;
border: 1px solid var(--accent);
border-radius: 6px;
background: #12283f;
cursor: pointer;
animation: pulse 1.6s ease-in-out infinite;
}
.alert:last-child {
margin-bottom: 0;
}
.alert b {
display: block;
}
.alert span {
color: var(--muted);
font-size: 11px;
}
@keyframes pulse {
50% {
border-color: #7cc0ff;
}
}
/* --- accounts --- */
#accounts {
padding: 8px;
overflow-y: auto;
}
.acct {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 9px;
margin-bottom: 3px;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
}
.acct:hover {
background: var(--surface-2);
}
.acct.active {
background: var(--surface-2);
border-color: var(--line);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
flex: none;
}
.dot.loading {
background: var(--warn);
}
.dot.ready {
background: var(--ok);
}
.dot.err {
background: var(--err);
}
.dot.off {
background: #566270;
}
.acct .state {
flex: none;
font-size: 10px;
color: var(--muted);
}
/* which Connect instance the account lives on */
.acct .inst {
flex: none;
padding: 1px 5px;
border: 1px solid var(--line);
border-radius: 4px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.4px;
color: var(--muted);
}
.acct:hover .inst {
display: none; /* make room for the tool buttons */
}
.acct:hover .state {
display: none; /* make room for the tool buttons */
}
.acct .label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acct .label.placeholder {
color: var(--muted);
font-style: italic;
}
.rename {
flex: 1;
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--accent);
border-radius: 4px;
background: #0d1117;
color: var(--text);
font: inherit;
outline: none;
}
.acct .badge {
min-width: 17px;
padding: 0 5px;
border-radius: 9px;
background: var(--accent);
color: #06101d;
font-size: 10px;
font-weight: 700;
text-align: center;
}
.acct .tools {
display: none;
gap: 4px;
}
.acct:hover .tools {
display: flex;
}
.acct .tools button {
border: 0;
background: none;
color: var(--muted);
cursor: pointer;
padding: 0 2px;
font-size: 12px;
}
.acct .tools button:hover {
color: var(--text);
}
/* --- footer --- */
footer {
padding: 10px;
border-top: 1px solid var(--line);
-webkit-app-region: no-drag;
}
footer button {
-webkit-app-region: no-drag;
}
#bulk-label {
margin-bottom: 5px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--muted);
}
#bulk,
#bulk2 {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
/* appearance:none everywhere if a button ever loses its styling it must
degrade to a dark rectangle, never a white UA-default widget. */
#bulk button,
#bulk2 button,
#add,
#signins {
appearance: none;
-webkit-appearance: none;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
font: inherit;
cursor: pointer;
}
#bulk button {
flex: 1 1 0;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
padding: 7px 4px;
font-size: 12px;
}
#bulk button:hover,
#bulk2 button:hover,
#add:hover,
#signins:hover {
border-color: var(--accent);
}
#bulk button:active,
#bulk2 button:active,
#add:active,
#signins:active {
background: #2a313b;
}
#bulk2 button {
flex: 1 1 0;
min-width: 0;
white-space: nowrap;
padding: 6px 4px;
font-size: 12px;
}
#signins {
width: 100%;
padding: 6px;
margin-top: 6px;
font-size: 12px;
color: var(--muted);
}
.bdot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
}
.bdot.av {
background: var(--ok);
}
.bdot.off {
background: #566270;
}
#add {
width: 100%;
padding: 7px;
}
#hint {
margin-top: 8px;
font-size: 10px;
line-height: 1.45;
color: var(--muted);
}
#credit {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--line);
font-size: 10px;
letter-spacing: 0.3px;
text-align: center;
color: var(--muted);
}
/* --- toast --- */
#toast {
position: fixed;
left: 10px;
right: 10px;
bottom: 10px;
z-index: 40;
padding: 8px 10px;
background: #0d1117;
border: 1px solid var(--line);
border-radius: 8px;
font-size: 11px;
line-height: 1.4;
}
#toast[hidden] {
display: none;
}
#incoming-pop {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(8, 10, 12, 0.82);
}
#incoming-pop[hidden] {
display: none;
}
.incoming-pop-card {
width: 100%;
padding: 16px 14px 14px;
border: 2px solid #3fb950;
border-radius: 10px;
background: #12283f;
text-align: center;
animation: pulse 1.2s ease-in-out infinite;
}
.incoming-pop-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: #7ee787;
}
#incoming-pop-name {
margin: 8px 0 14px;
font-size: 16px;
font-weight: 700;
line-height: 1.3;
}
#incoming-pop-go {
appearance: none;
-webkit-appearance: none;
width: 100%;
padding: 10px;
border: 0;
border-radius: 8px;
background: #3fb950;
color: #0d1117;
font: inherit;
font-weight: 700;
cursor: pointer;
}
/* --- saved sign-ins panel --- */
#creds {
position: fixed;
inset: 0;
z-index: 50;
overflow-y: auto;
overflow-x: clip;
padding: 14px;
background: var(--bg);
}
#creds[hidden] {
display: none;
}
.creds-head {
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.creds-note {
font-size: 10px;
line-height: 1.5;
color: var(--muted);
margin-bottom: 12px;
}
.creds-row {
margin-bottom: 12px;
}
.creds-row.cleared {
opacity: 0.35;
}
.creds-name {
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
}
.creds-line {
display: flex;
flex-direction: column;
gap: 4px;
}
.creds-line .rename {
width: 100%;
}
/* per-account action bar */
.creds-rowbar {
display: flex;
align-items: center;
gap: 6px;
margin-top: 5px;
}
.creds-rowbar button {
appearance: none;
padding: 3px 9px;
border: 1px solid var(--line);
border-radius: 5px;
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.creds-rowbar button:hover {
border-color: var(--accent);
}
.creds-rowbar button.primary {
border-color: var(--accent);
background: var(--accent);
color: #06101d;
font-weight: 600;
}
.creds-status {
flex: 1;
min-width: 0;
font-size: 10px;
color: var(--ok);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.creds-forget {
flex: none;
border: 0;
background: none;
color: var(--muted);
font-size: 10px;
cursor: pointer;
padding: 0;
}
.creds-forget:hover {
color: var(--err);
}
.creds-row.cleared .creds-forget {
color: var(--err);
font-weight: 700;
}
.creds-actions {
display: flex;
gap: 6px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.creds-actions button {
appearance: none;
flex: 1 1 0;
min-width: 0;
white-space: nowrap;
padding: 8px 4px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.creds-actions button:hover {
border-color: var(--accent);
}
.creds-actions button.primary {
border-color: var(--accent);
background: var(--accent);
color: #06101d;
font-weight: 600;
}

436
ui/ui.js Normal file
View File

@ -0,0 +1,436 @@
const $alerts = document.getElementById("alerts");
const $accounts = document.getElementById("accounts");
const $instance = document.getElementById("instance");
const $toast = document.getElementById("toast");
const $incomingPop = document.getElementById("incoming-pop");
const $incomingPopName = document.getElementById("incoming-pop-name");
const $incomingPopGo = document.getElementById("incoming-pop-go");
// Electron has no window.prompt(), so renames happen through an inline input.
// `editing` survives re-renders so the field isn't wiped mid-typing.
let editing = null;
let lastState = { accounts: [], alerts: [] };
// Belt & braces alongside overflow-x:clip — nothing may ever scroll the
// sidebar sideways (a focused input once did, which clipped all content).
function resetScroll() {
for (const el of [document.scrollingElement, document.body, $accounts]) {
if (el && el.scrollLeft) el.scrollLeft = 0;
}
}
/* ---------------- alerts ---------------- */
function renderAlerts(alerts) {
$alerts.innerHTML = "";
if (!alerts.length) {
const e = document.createElement("div");
e.className = "empty";
e.textContent = "No waiting chats";
$alerts.append(e);
return;
}
for (const a of alerts) {
const el = document.createElement("div");
el.className = "alert";
const b = document.createElement("b");
b.textContent = a.label;
const s = document.createElement("span");
s.textContent = a.kind === "reply" ? a.text || "New message" : a.queue || "waiting";
el.append(b, s);
el.onclick = () => api.focusAlert(a.accountId);
$alerts.append(el);
}
}
/* ---------------- rename editor ---------------- */
function startEdit(id) {
editing = id;
render(lastState);
}
function commitEdit(id, value) {
if (editing !== id) return;
editing = null;
api.renameAccount(id, value);
}
// Native popups (Chromium autofill, macOS spelling/substitution) anchor to text
// inputs and float OVER the window — an orphaned one is what once appeared as a
// white box across the footer. Disable everything that can summon one.
function plainInput() {
const input = document.createElement("input");
input.autocomplete = "off";
input.spellcheck = false;
input.setAttribute("autocapitalize", "off");
input.setAttribute("autocorrect", "off");
return input;
}
function buildEditor(a) {
const input = plainInput();
input.className = "rename";
input.value = a.label || a.agentName || "";
input.placeholder = a.agentName || "Account name";
input.onclick = (e) => e.stopPropagation();
input.onkeydown = (e) => {
e.stopPropagation();
if (e.key === "Enter") commitEdit(a.id, input.value);
if (e.key === "Escape") {
editing = null;
render(lastState);
}
};
input.onblur = () => commitEdit(a.id, input.value);
setTimeout(() => {
input.focus({ preventScroll: true }); // focus scrolling is what broke alignment
input.select();
resetScroll();
}, 0);
return input;
}
/* ---------------- account rows ---------------- */
function dotClass(a) {
if (a.loading) return "dot loading";
if (a.stayOut && a.signedIn !== true) return "dot off"; // deliberate sign-out, not an error
if (a.signedIn === false) return "dot err";
if (a.stateType === "routable") return "dot ready";
if (a.stateType === "offline") return "dot off";
if (a.agentName) return "dot ready";
return "dot";
}
function stateText(a) {
if (a.stayOut && a.signedIn !== true) return "signed out";
if (a.signedIn === false) return "sign in";
return a.stateName || "";
}
function buildRow(a, activeId) {
const el = document.createElement("div");
el.className = "acct" + (a.id === activeId ? " active" : "");
el.onclick = () => api.activate(a.id);
el.oncontextmenu = (e) => {
e.preventDefault();
api.showAccountMenu(a.id);
};
const dot = document.createElement("span");
dot.className = dotClass(a);
el.append(dot);
if (editing === a.id) {
el.append(buildEditor(a));
return el;
}
const label = document.createElement("span");
label.className = "label";
label.textContent = a.display;
if (!a.label && !a.agentName) label.classList.add("placeholder");
label.title =
(a.agentName ? `Signed in as ${a.agentName}` : "Not signed in yet") +
`${a.instanceLabel || ""}`;
label.ondblclick = (e) => {
e.stopPropagation();
startEdit(a.id);
};
el.append(label);
// Which Connect instance this account lives on (US / EU).
if (a.instanceShort) {
const inst = document.createElement("span");
inst.className = "inst";
inst.textContent = a.instanceShort;
inst.title = a.instanceLabel;
el.append(inst);
}
const st = stateText(a);
if (st) {
const chip = document.createElement("span");
chip.className = "state";
chip.textContent = st;
el.append(chip);
}
if (a.waiting) {
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = a.waiting;
el.append(badge);
}
const tools = document.createElement("span");
tools.className = "tools";
for (const [glyph, title, fn] of [
["✎", "Rename", () => startEdit(a.id)],
["⟳", "Reload", () => api.reload(a.id)],
["✕", "Remove account", () => api.removeAccount(a.id)],
]) {
const b = document.createElement("button");
b.textContent = glyph;
b.title = title;
b.onclick = (e) => {
e.stopPropagation();
fn();
};
tools.append(b);
}
el.append(tools);
return el;
}
/* ---------------- render ---------------- */
function renderIncomingPop(alerts) {
const waiting = (alerts || []).filter((a) => a.kind !== "reply");
if (!waiting.length) {
$incomingPop.hidden = true;
$incomingPopGo.onclick = null;
return;
}
$incomingPop.hidden = false;
$incomingPopName.textContent = waiting.map((a) => a.label).join(" · ");
$incomingPopGo.onclick = (e) => {
e.stopPropagation();
api.focusAlert(waiting[0].accountId);
};
}
function render(state) {
lastState = state;
$instance.textContent = (state.instances || []).map((i) => i.label).join(" · ");
renderAlerts(state.alerts);
renderIncomingPop(state.alerts);
$accounts.innerHTML = "";
for (const a of state.accounts) $accounts.append(buildRow(a, state.activeId));
resetScroll();
}
api.onState(render);
api.onError(({ message }) => console.warn("account error:", message));
api.onMenuAction((a) => {
if (a.type === "rename") startEdit(a.id);
if (a.type === "creds") openCreds();
});
/* ---------------- toast ---------------- */
let toastTimer;
api.onToast(({ text }) => {
$toast.textContent = text;
$toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ($toast.hidden = true), 6000);
});
/* ---------------- top-level controls ---------------- */
// Every account belongs to one Connect instance, so adding starts by picking
// which one — then straight into naming, as before.
const $add = document.getElementById("add");
$add.onclick = async (e) => {
e.stopPropagation();
const instances = lastState.instances || [];
const create = async (instId) => {
const id = await api.addAccount(instId);
if (id) startEdit(id);
};
if (instances.length <= 1) return void create(instances[0]?.id);
const instId = await api.showInstanceMenu();
if (instId) create(instId);
};
document.getElementById("all-available").onclick = () => api.setStatusAll("routable");
document.getElementById("all-offline").onclick = () => api.setStatusAll("offline");
document.getElementById("all-signin").onclick = () => api.signInAll();
document.getElementById("all-signout").onclick = () => api.signOutAll();
/* ---------------- saved sign-ins panel ---------------- */
const $creds = document.getElementById("creds");
function closeCreds() {
$creds.hidden = true;
$creds.innerHTML = ""; // never leave typed passwords sitting in the DOM
}
function showToast(text, ms = 6000) {
$toast.textContent = text;
$toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => ($toast.hidden = true), ms);
}
async function openCreds() {
const rows = await api.credsList();
$creds.innerHTML = "";
const head = document.createElement("div");
head.className = "creds-head";
head.textContent = "Saved sign-ins";
const note = document.createElement("div");
note.className = "creds-note";
note.textContent =
"Stored encrypted on this computer only (OS keychain). Filled only on the official " +
"Amazon Connect sign-in page for each account's instance. Leave a password blank to keep " +
"the one already saved.";
$creds.append(head, note);
const cleared = new Set();
const inputs = new Map(); // id -> {u, p, status}
// Collects one row (or every row) into the shape creds:save expects.
const entryFor = (id) => {
const { u, p } = inputs.get(id);
return { id, username: u.value, password: p.value || null, clear: cleared.has(id) };
};
const allEntries = () => [...inputs.keys()].map(entryFor);
const flash = (id, text) => {
const s = inputs.get(id)?.status;
if (!s) return;
s.textContent = text;
clearTimeout(s._t);
s._t = setTimeout(() => (s.textContent = ""), 4000);
};
// After saving, the password box is empty again but a secret now exists —
// reflect that so the row doesn't look unsaved.
const markSaved = (id) => {
const { p } = inputs.get(id);
p.value = "";
p.placeholder = "•••••• saved — blank keeps it";
};
const saveOne = async (id) => {
const res = await api.credsSave([entryFor(id)]);
if (!res?.ok) return showToast(res?.error || "Saving failed.", 8000);
if (cleared.has(id)) {
cleared.delete(id);
inputs.get(id).row.classList.remove("cleared");
inputs.get(id).p.placeholder = "password";
flash(id, "forgotten");
} else {
markSaved(id);
flash(id, "saved ✓");
}
return true;
};
for (const r of rows) {
const row = document.createElement("div");
row.className = "creds-row";
const name = document.createElement("div");
name.className = "creds-name";
name.textContent = `${r.display} · ${r.instanceShort}`;
const u = plainInput();
u.className = "rename";
u.placeholder = "login username / email";
u.value = r.username;
const p = plainInput();
p.className = "rename";
p.type = "password";
p.placeholder = r.hasPassword ? "•••••• saved — blank keeps it" : "password";
const bar = document.createElement("div");
bar.className = "creds-rowbar";
const bSave = document.createElement("button");
bSave.type = "button";
bSave.textContent = "Save";
bSave.title = "Save this account's sign-in";
bSave.onclick = () => saveOne(r.id);
const bSignIn = document.createElement("button");
bSignIn.type = "button";
bSignIn.className = "primary";
bSignIn.textContent = "Sign in";
bSignIn.title = "Save, then sign this account in with these credentials";
bSignIn.onclick = async () => {
if (!(await saveOne(r.id))) return;
flash(r.id, "signing in…");
api.signIn(r.id);
};
const forget = document.createElement("button");
forget.type = "button";
forget.className = "creds-forget";
forget.textContent = "forget";
forget.title = "Mark this saved sign-in for deletion, then Save";
forget.onclick = () => {
if (cleared.has(r.id)) {
cleared.delete(r.id);
row.classList.remove("cleared");
} else {
cleared.add(r.id);
row.classList.add("cleared");
}
};
const status = document.createElement("span");
status.className = "creds-status";
bar.append(bSave, bSignIn, status, forget);
inputs.set(r.id, { u, p, status, row });
const line = document.createElement("div");
line.className = "creds-line";
line.append(u, p);
row.append(name, line, bar);
$creds.append(row);
}
const actions = document.createElement("div");
actions.className = "creds-actions";
const saveAll = document.createElement("button");
saveAll.type = "button";
saveAll.textContent = "Save all";
saveAll.onclick = async () => {
const res = await api.credsSave(allEntries());
if (!res?.ok) return showToast(res?.error || "Saving failed.", 8000);
for (const id of inputs.keys()) {
if (cleared.has(id)) continue;
markSaved(id);
}
cleared.clear();
for (const { row } of inputs.values()) row.classList.remove("cleared");
showToast(`Saved sign-ins for ${res.saved} account(s).`);
};
const signInAll = document.createElement("button");
signInAll.type = "button";
signInAll.className = "primary";
signInAll.textContent = "Sign in all";
signInAll.onclick = async () => {
const res = await api.credsSave(allEntries());
if (!res?.ok) return showToast(res?.error || "Saving failed.", 8000);
closeCreds();
api.signInAll();
};
const close = document.createElement("button");
close.type = "button";
close.textContent = "Close";
close.onclick = closeCreds;
actions.append(saveAll, signInAll, close);
$creds.append(actions);
$creds.hidden = false;
}
document.getElementById("signins").onclick = openCreds;
addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$creds.hidden) closeCreds();
});
api.getState();