diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a087d0..15c90f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ product overview. ## Unreleased +- Add an opt-in admin sign-in alternative using the admin password and a private, expiring Slack + verification code for existing administrators, including sensitive-action reauthentication. + - Version login-page scripts with their HTML so cached pre-upgrade code cannot reference removed fields. - Use the existing Users list and admin role for dashboard passkeys and invitations. Existing diff --git a/FEATURES.md b/FEATURES.md index 61d61146..025ea3f7 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -10,6 +10,18 @@ Slack’s native running/Stop label shows the resolved model and explicitly configured effort, plus compact live phases (Thinking, Using tools, Writing, Waiting, Queued, Coordinating agents). Engine fallback replaces both model and effort; unset effort is omitted rather than guessed. +## Optional password and Slack code sign-in + +- Settings → Admin access can enable an alternative to passkeys: the configured admin password + followed by an eight-digit code delivered privately to an existing Slack administrator. + The option is off by default; password alone never grants dashboard access. Existing passkeys + remain available. A recently verified, identity-linked initial setup can enable this option + without first registering a passkey. +- Codes expire after five minutes, are bound to the requesting browser, accept at most five + guesses and are single-use. Delivery failures, role revocation, password changes and disabling + the option invalidate pending authority. Sensitive actions can repeat password and Slack + verification. Sessions retain the normal idle/absolute expiry and current-role checks. + ## User-linked dashboard passkeys - The existing **Users** directory and its admin role govern dashboard access. Each credential @@ -27,7 +39,7 @@ Slack’s native running/Stop label shows the resolved model and explicitly conf session, with five attempts maximum. Matching names never assign ownership. Unlinked accounts can complete this verification but cannot use ordinary admin APIs once known admins exist. Initial password setup follows the same proof before enrollment; fresh installs without known - admins retain temporary operator setup. First enrollment permanently retires password login. + admins retain temporary operator setup. First enrollment retires the password-only bootstrap path; the optional password plus Slack code flow requires explicit enablement. - Host operators recover a known admin with `npm run admin:recover -- --user `. Recovery revokes its keys/sessions and prints a short-lived registration-only link; it never restores password login. Private keys remain with authenticators; SQLite stores public keys diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 3e7a46d5..20359524 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,38 @@ # ChannelGate — Test Plan +## Optional password and Slack code authentication (2026-10-28) + +- Automated scratch HTTP fixture: `test/admin-password-slack.test.js` and + `test/admin-password-slack-race.test.js`. Use fake private delivery, + disposable passwords and existing Slack Admin/non-admin users. Verify disabled-by-default, + password-only denial, correct browser-bound code login after passkey enrollment, replay/expiry/ + guess limits, private delivery failure, role and credential/settings revocation. Never send + fixture codes to deployed users. +- Browser fixture: `test/admin-password-slack-browser.test.js` exercises Settings → Admin access enablement, alternate sign-in and sensitive + reauthentication and sign-out on disable without a WebAuthn device. Chromium verification: + all 15 combined passkey/initial-setup/alternate-login assertions passed, zero skips; the + extended disable/sign-out browser fixture also passed. Private delivery is simulated. +- Live acceptance pending, engine-independent: this HTTP/browser authentication path never calls + either engine. On a disposable gateway with an existing Slack administrator and working bot DM + delivery, enable the checkbox using recent admin verification, sign out, enter the password + and own Slack member ID, then enter the privately received code. Pass only when the dashboard + opens as that exact existing user with no passkey prompt. Repeat a sensitive action with code + reauthentication. Confirm a wrong/replayed code and a non-admin are refused; disabling the + checkbox removes alternate login and invalidates its sessions. Check from the RustDesk browser + through a localhost SSH tunnel. Record screenshots/HTTP evidence without codes/passwords. +- Local regression: `npm run test:coverage -- --test-concurrency=4` passed with 4,120 + passed / 38 opt-in skipped / zero failed; coverage 93.91% lines, 86.55% branches, 90.27% + functions. An initial unconstrained run hit the container process limit and was terminated; + it is not counted as a passing run. Security-coverage, secret scan and production dependency + audit at the high severity threshold passed (three existing moderate advisories remain). +- CI fixture isolation: `test/web-security.test.js` now initializes scratch paths before + importing security helpers. Its isolated 15-test run under a fresh HOME created no runtime + files there; this fixes the existing real-home leak without relaxing the guard. +- Gate maintenance: linked-workspace authorization cases in `test/authorization.test.js` + restore the existing 95% branch floor (measured 96.81%); no production policy or floor changed. +- Private QA registry update pending: the requesting user's personal Airtable connection is not + connected in this run. Do not treat the missing registry or unexecuted live case as passed. + ## Linked working-folder setup acceptance Re-verification (2026-10-26): regression coverage now checks source `excludedUsers` and restricted-channel policy through linked DMs, explicit-unlink/source-departure section-skill retention, DM template preservation, established promoted-owner selection, and immediate follower SSH disconnection after source revocation (20-minute timer disabled as a fallback). `test/skills-workspace-sync.test.js` and `test/ssh-broker.test.js` cover these gaps. Updated full suite: 4,117 passed, 37 skipped, zero failures; Chromium linked-folder fixture passed. Actual Claude and Codex CLI smoke checks read a source-scoped skill from a generated linked workspace and returned its unique marker. Codex used the gateway's Worker/container mode; an initial nested read-only sandbox attempt failed with a bubblewrap capability error. These are CLI smoke checks, not a Slack end-to-end acceptance pass. diff --git a/documentation/package-lock.json b/documentation/package-lock.json index 4d4e49d4..3b85a640 100644 --- a/documentation/package-lock.json +++ b/documentation/package-lock.json @@ -4608,9 +4608,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -5068,9 +5068,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", - "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", "license": "MIT", "engines": { "node": ">=22.19.0" @@ -5472,9 +5472,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/documentation/src/content/docs/controls.mdx b/documentation/src/content/docs/controls.mdx index 5c44f2ce..e176e3a6 100644 --- a/documentation/src/content/docs/controls.mdx +++ b/documentation/src/content/docs/controls.mdx @@ -9,7 +9,7 @@ import { controlPages } from '../../data/control-reference.mjs'; Ask the agent for an outcome in ordinary language. It can use the controls below when its current permissions, platform, and connections allow them. These reference pages explain the exact tool names, inputs, results, and restrictions for people building integrations or checking how an action works. -The gateway defines **110 controls**. The separate [skills-library service](/docs/controls/skills-library) defines **10 more**. A run receives an authorized subset; a tool appearing here does not mean it is enabled in every conversation. The library endpoint uses its own access tokens. +The gateway defines **121 controls**. The separate [skills-library service](/docs/controls/skills-library) defines **10 more**. A run receives an authorized subset; a tool appearing here does not mean it is enabled in every conversation. The library endpoint uses its own access tokens. For practical workflows, browse the [feature directory](/docs/features). For deployment and conversation defaults, open [configuration](/docs/configuration). The [functionality map](/docs/functionality) connects the broader product areas to their guides. diff --git a/documentation/src/content/docs/controls/accounts-and-secrets.md b/documentation/src/content/docs/controls/accounts-and-secrets.md index ef914bfe..c87c82f8 100644 --- a/documentation/src/content/docs/controls/accounts-and-secrets.md +++ b/documentation/src/content/docs/controls/accounts-and-secrets.md @@ -159,3 +159,55 @@ An email account is a mailbox's IMAP and SMTP settings entered once on one form ## Related guides [Accounts](/docs/features/connected-accounts), [environment configuration](/docs/configuration/environment-variables), [secrets](/docs/features/secrets), [Composio SDK](/docs/features/composio-sdk), and [Toolbox/Make](/docs/features/toolbox-and-make). + +## list_email_accounts + +**Inputs:** None. + +**Scope and result:** Lists this conversation's connected email accounts and the verified requester's personal accounts, with address, scope, server endpoints, and sending availability. Never returns passwords. Connect accounts through Settings → Email accounts (MCP page for the conversation, You page for personal accounts), not through chat. Personal mail read in a shared conversation appears in that shared thread. Untrusted API author attribution does not grant personal accounts. + +The email controls below accept optional `account`: a connected account name, `personal/`, `channel/`, or email address. Omit it only when exactly one account is available. Optional `mailbox` defaults to `INBOX`; message `uid` values are scoped to that mailbox. + +## email_mailboxes + +**Optional:** `account`. + +**Scope and result:** Lists selectable folders and their special roles (Inbox, Sent, Drafts, Archive, Trash, Junk) for the selected connected account. + +## email_search + +**Optional:** `account`, `mailbox`, `query`, `from`, `to`, `subject`, `since`, `before`, `unseen`, `flagged`, `limit` (integer 1–50; default 20). + +**Scope and result:** Lists matching messages newest first, including UID, date, sender, subject, unread/flag marks, and attachment count. Filters combine with AND; no filters lists newest messages. `since` and `before` are ISO dates. Results include the total match count. Use the returned UID with the message controls. + +## email_read + +**Required:** `uid` (positive integer). **Optional:** `account`, `mailbox`, `max_chars` (integer 500–100000; default 20000). + +**Scope and result:** Reads headers, text body (plain text preferred, HTML converted), and attachment part identifiers. Reports body truncation explicitly. + +## email_download_attachment + +**Required:** `uid` (positive integer), `part` (attachment part ID from `email_read`). **Optional:** `account`, `mailbox`. + +**Scope and result:** Downloads an attachment into this conversation's workspace under `uploads/email//` and returns its local path. Maximum attachment size is 50 MB. The path can be read locally or shared using the conversation's file controls. + +## email_move + +**Required:** `uid` (positive integer), `to` (destination mailbox path). **Optional:** `account`, `mailbox`. + +**Scope and result:** Moves a message into another folder, such as Archive or Trash. Returns the source/destination and new UID when supplied by the server. List valid destinations with `email_mailboxes`. + +## email_set_flag + +**Required:** `uid` (positive integer), `flag` (`seen`, `unseen`, `flagged`, `unflagged`, or `answered`). **Optional:** `account`, `mailbox`. + +**Scope and result:** Changes the selected message's read, flagged, or answered state and returns an acknowledgement. + +## email_send + +**Required:** `to` (nonempty array of recipient addresses), `subject` (string), `text` (plain-text body). **Optional:** `account`, `cc`, `bcc` (address arrays), `html` (HTML alternative), `attachments` (workspace-relative file paths), `in_reply_to` (original Message-ID), `reply_to` (address). + +**Scope and result:** Sends from the selected connected account's address and reports accepted/rejected recipients, Message-ID, and whether a Sent copy was saved. `in_reply_to` sets both In-Reply-To and References. Attachments are confined to the conversation workspace, at most ten files and 25 MB total. + +**Restrictions:** Sending must be enabled on the account. Confirm recipients and content unless the user already supplied them. Personal accounts send as that person. Mail credentials remain in the daemon, outside agent containers. diff --git a/documentation/src/content/docs/controls/slack.md b/documentation/src/content/docs/controls/slack.md index 9f398cfa..a60a7e35 100644 --- a/documentation/src/content/docs/controls/slack.md +++ b/documentation/src/content/docs/controls/slack.md @@ -235,3 +235,29 @@ These tools use the gateway’s Slack bot token and trusted current conversation **Scope and result:** Asks Slack for a file descriptor, proves sharing in the current conversation, and downloads into this thread’s uploads folder. Returns a local path, MIME type when available, and size. A existing confined download is reused. **Limits:** At most 500 MiB per attachment. The bot must have file access and Slack must report the file shared here. It returns no private URL/token. Download refusals and unavailable metadata do not count as content inspection. + +## open_code_channel + +**Required:** `task` (string, 3–2000 characters). **Optional:** `name` (string, up to 80 characters), `mode` (`auto`, `worktree`, or `in_place`; default `auto`), `private` (boolean; default inherits the origin channel). + +**Scope and result:** Requests a dedicated Slack code channel for an explicitly requested task. A human must approve the card before creation. The new channel inherits the origin workspace, access policy, secrets, and skills. `auto` creates a task branch and worktree when the workspace is a repository; otherwise it uses the channel's files. Returns the channel and workspace/branch outcome; the daemon starts the first turn when available. + +**Restrictions:** Requires an originating Slack message in a configured channel. Not available from a DM, another code channel, or a scheduled/background run without an originating message. Call only when the requester explicitly asks for a code channel. + +## update_code_channel + +**Optional:** `title` (string, up to 200 characters), `refresh_diff` (boolean), `context_items` (up to five objects with required `key` and `label`, optional `icon` and `url`), `view` (object). + +The `view` object requires `type` (`html`, `block_kit`, `canvas`, or `pull_request`) and accepts `key`, `name`, `content` (complete self-contained HTML), `blocks` (up to 50 Block Kit blocks), `canvas_id`, and `pr_url` as appropriate for the type. Reusing a stable `key` replaces that artifact tab. + +**Scope and result:** Updates the current code channel's title, context bar, artifact tab, or Code diff independently. Context items merge with defaults; the complete context bar holds up to five items. Returns an outcome for each requested change. The Code diff normally refreshes after each turn; `refresh_diff:true` forces a refresh. + +**Restrictions:** Only available inside an active Slack code channel. HTML previews render sandboxed. An archived code channel cannot be updated. + +## archive_code_channel + +**Optional:** `summary` (string, up to 3900 characters). + +**Scope and result:** Requests archival of the current Slack code channel after its task is done and the requester agrees. A human must approve the card. Posts the wrap-up, shares it into the origin thread, and archives the channel. History and artifact tabs remain readable and searchable; the task branch remains on disk. + +**Restrictions:** Only available inside a Slack code channel. Repeated archival reports that the channel is already archived. diff --git a/documentation/src/content/docs/features/admin-dashboard.md b/documentation/src/content/docs/features/admin-dashboard.md index 9e8cdb59..4b801da6 100644 --- a/documentation/src/content/docs/features/admin-dashboard.md +++ b/documentation/src/content/docs/features/admin-dashboard.md @@ -7,10 +7,18 @@ The admin website runs as part of your ChannelGate installation. It manages the ## Sign in -By default, the daemon serves the interface on `http://localhost:4747`. An operator can change the port or put a trusted tunnel/reverse proxy in front of it. On first boot without a configured password, ChannelGate generates an admin password once and stores its hash. Use the password supplied by the installation's operator. +By default, the daemon serves the interface on `http://localhost:4747`. An operator can change the port or put a trusted tunnel/reverse proxy in front of it. On first boot without a configured password, ChannelGate generates an admin password once and stores its hash. Use the password supplied by the installation's operator for initial setup, then verify and link your existing gateway administrator user. Normal sign-in uses a passkey, with optional password and Slack code sign-in as described below. A public deployment should set **Settings → Connection → Public URL** to its reachable HTTPS origin. This also gives approval links a reachable address. Setting Public URL does not create a tunnel or open a firewall. +## Optional password and Slack code sign-in + +A linked Slack administrator can enable **Settings → Admin access → Password and Slack code** after a fresh sign-in. It is off by default. Keep the current shared admin password or set a new one of at least 12 characters. During initial setup, verify and link the administrator first, then use this option to complete setup without creating a passkey. + +On the sign-in page, choose **Sign in with password and Slack code**, enter the shared admin password and your Slack user ID, then enter the eight-digit code delivered privately by the gateway bot. Codes expire after five minutes, work only in the requesting browser, and allow at most five incorrect guesses. This option requires a current gateway Slack administrator and working private Slack delivery; approved members alone cannot use it. Passkeys remain available. + +Changing the shared password or disabling this option revokes existing password-and-code sessions. Removing the user's administrator role also revokes access. Disabling it while signed in this way signs out that browser; retain another usable sign-in method before disabling it. + ## Choose the right page - **[Overview](/docs/features/usage-and-costs):** run, token, cost, model, user, conversation, and skill summaries. @@ -31,7 +39,7 @@ Settings display inherited and explicit values where supported. Blank password/t Platform connections can be connected or reconnected without restarting the whole daemon. Runtime/image changes may affect future container starts; inspect the resulting status and verify a real task after a change. -Chat admins and the password-authenticated web session are distinct ways of authorizing control actions. Being an approved chat user does not itself provide the admin website password. +Dashboard sessions are linked to an existing gateway administrator user. Being an approved chat user alone does not grant dashboard access. The shared password alone cannot grant a full dashboard session. ## Related guides diff --git a/public/admin-access.js b/public/admin-access.js index e3a00466..4a210ec9 100644 --- a/public/admin-access.js +++ b/public/admin-access.js @@ -2,12 +2,89 @@ import { api } from "./admin-api.js"; const passkeys = () => window.ChannelGatePasskeys; +// Passwords and codes stay only in this temporary form; never browser storage. +async function passwordSlackReauth(me) { + const previousFocus = document.activeElement; + const modal = document.createElement("div"); + modal.className = "modal"; + modal.innerHTML = ``; + document.body.append(modal); + const at = (name) => modal.querySelector(`[data-auth="${name}"]`); + at("passkey").hidden = !me.passkeys?.length; + let requestId = ""; + let busy = false; + return new Promise((resolve, reject) => { + function finish(value, error) { + at("password").value = ""; + at("code").value = ""; + document.removeEventListener("keydown", onKey); + modal.remove(); + previousFocus?.focus(); + if (error) reject(error); else resolve(value); + } + function onKey(event) { + if (event.key === "Escape" && !busy) { event.preventDefault(); finish(false, new Error("Identity verification cancelled.")); } + if (event.key === "Tab") { + const controls = [...modal.querySelectorAll("input, button")].filter((item) => !item.disabled && !item.closest("[hidden]")); + const first = controls[0]; + const last = controls.at(-1); + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); } + } + } + document.addEventListener("keydown", onKey); + at("cancel").addEventListener("click", () => finish(false, new Error("Identity verification cancelled."))); + at("passkey").addEventListener("click", () => finish(false)); + modal.querySelector("form").addEventListener("submit", async (event) => { + event.preventDefault(); + if (busy) return; + busy = true; + for (const button of modal.querySelectorAll("button")) button.disabled = true; + at("error").textContent = ""; + try { + if (!requestId) { + let result; + try { + result = await api("/api/auth/password-slack/options", { method: "POST", body: JSON.stringify({ password: at("password").value, userId: me.account.userId }) }); + } finally { at("password").value = ""; } + requestId = result.requestId; + at("password").required = false; + at("password-label").hidden = true; + at("code-label").hidden = false; + at("code").required = true; + at("status").textContent = "Check your private Slack messages. The code expires in 5 minutes."; + at("submit").textContent = "Verify code"; + at("code").focus(); + } else { + try { + await api("/api/auth/password-slack/verify", { method: "POST", body: JSON.stringify({ requestId, code: at("code").value.trim() }) }); + } finally { at("code").value = ""; } + finish(true); + } + } catch (error) { at("error").textContent = error.message; } + finally { + busy = false; + for (const button of modal.querySelectorAll("button")) button.disabled = false; + } + }); + at("password").focus(); + }); +} + // Called from a user action (settings, secret reveal, account/key management). The server is // authoritative about freshness; never cache a password or an assertion in browser storage. export async function freshAdminAuth({ force = false } = {}) { const me = await api("/api/auth/me", { cache: "no-store" }); if (me.bootstrap) throw new Error("Create your first passkey under Settings → Admin access before continuing."); if (!force && me.fresh) return true; + if (me.passwordSlackEnabled && /^[UW][A-Z0-9]{8,}$/.test(me.account?.userId || "") && await passwordSlackReauth(me)) return true; try { const { options } = await api("/api/auth/reauth/options", { method: "POST", body: "{}" }); const response = await passkeys().get(options); @@ -67,7 +144,13 @@ export async function mountAdminAccess(container, { confirm = async () => false,

- + +
+ +

Admins can sign in without a passkey using the shared dashboard password and a private Slack code. Requires a connected Slack admin account. Disabling this option signs out password and Slack code sessions.

+ + +
@@ -83,7 +166,7 @@ export async function mountAdminAccess(container, { confirm = async () => false, let linkRequestId = ""; function syncControls() { if (!active()) return; - for (const control of container.querySelectorAll("button, select")) control.disabled = !ready || busy || control.dataset.unavailable === "true"; + for (const control of container.querySelectorAll("button, select, input[type=checkbox]")) control.disabled = !ready || busy || control.dataset.unavailable === "true"; } syncControls(); function message(text, error = false) { @@ -126,6 +209,10 @@ export async function mountAdminAccess(container, { confirm = async () => false, if (!users.length) at("link-status").textContent = "No existing admin user is available to link. Configure your chat connection and admin users first."; } at("bootstrap").hidden = !me.bootstrap; + at("password-slack-label").textContent = me.bootstrap ? "Existing setup password (required to enable sign-in)" : "Dashboard password (leave blank to keep the current password)"; + at("password-slack-enabled").checked = Boolean(me.passwordSlackEnabled); + at("password-slack-save").dataset.unavailable = String(!me.account.userId || me.linkRequired); + at("password-slack-enabled").dataset.unavailable = String(!me.account.userId || me.linkRequired); at("register").textContent = me.passkeys.length ? "Add a passkey" : "Create your first passkey"; at("register").dataset.unavailable = String(Boolean(me.linkRequired)); at("keys").replaceChildren(...me.passkeys.map((key) => accessRow( @@ -171,6 +258,27 @@ export async function mountAdminAccess(container, { confirm = async () => false, at("link-verify-form").hidden = true; at("link-status").textContent = "Send a new verification code to the selected user."; }); + async function savePasswordSlack() { + const enabled = at("password-slack-enabled").checked; + const password = at("password-slack-password").value; + at("password-slack-password").value = ""; + try { + if (!me.bootstrap) await freshAdminAuth(); + const result = await api("/api/auth/password-slack/settings", { method: "PUT", body: JSON.stringify({ enabled, ...(password ? { password } : {}) }) }); + if (result.signedOut) { location.replace("/login.html"); return; } + await refresh(); + message(enabled ? "Password and Slack code sign-in enabled." : "Password and Slack code sign-in disabled."); + if (enabled && !me.bootstrap && !me.passkeys.length) location.replace("/settings#set-admin-access"); + } catch (error) { + at("password-slack-enabled").checked = Boolean(me.passwordSlackEnabled); + throw error; + } + } + at("password-slack-form").addEventListener("submit", (event) => { + event.preventDefault(); + void run(savePasswordSlack); + }); + at("password-slack-enabled").addEventListener("change", () => { void run(savePasswordSlack); }); at("register-form").addEventListener("submit", (event) => { event.preventDefault(); void run(async () => { diff --git a/public/app.js b/public/app.js index c194f1b5..07ca6c3c 100644 --- a/public/app.js +++ b/public/app.js @@ -5239,7 +5239,7 @@ document.getElementById("dash-source").addEventListener("change", () => loadDash // ── Boot ───────────────────────────────────────────────────────────────────────── async function init() { const identity = await api("/api/auth/me", { cache: "no-store" }); - if (identity.linkRequired) { + if (identity.linkRequired || identity.bootstrap) { // Existing unlinked passkeys can authenticate, but ordinary admin APIs stay closed until // their owner proves the existing gateway identity. Keep that remedy reachable on its own. document.querySelector(".app").style.display = "none"; @@ -5248,7 +5248,7 @@ async function init() { const card = document.createElement("div"); card.className = "setcard identity-link-card"; const title = document.createElement("h2"); - title.textContent = "Link your gateway admin user"; + title.textContent = identity.linkRequired ? "Link your gateway admin user" : "Complete dashboard access setup"; const access = document.getElementById("admin-access"); card.append(title, access); gate.append(card); diff --git a/public/login.html b/public/login.html index 3d6d3204..3d84cce9 100644 --- a/public/login.html +++ b/public/login.html @@ -56,9 +56,26 @@
+