diff --git a/devlog/_plan/260926_kiro_lb_parity2/000_plan.md b/devlog/_plan/260926_kiro_lb_parity2/000_plan.md index f46b495eeb4..49971f21ff7 100644 --- a/devlog/_plan/260926_kiro_lb_parity2/000_plan.md +++ b/devlog/_plan/260926_kiro_lb_parity2/000_plan.md @@ -181,3 +181,9 @@ its doc against the landed lower layer anyway): | 070 | Note at 070:390 names `tests/oauth/kiro-refusal-transport.test.ts` and `tests/oauth/kiro-account-capacity.test.ts`, which no layer registers | Point at the landed 030/040 test names instead | | 070 | Health projection omits the `family` argument to `isCooled` | Pass it for future-proofing | | 030/040/050/020 | 010 renamed the in-memory verdict timestamp to `observedAt`, dropped `overageEnabled`, and moved `kiroManagementHost` to 050 (wp2 P) | Correct 030:4,257,271-272; 040:510; 020:9; 030:583; 050 references at each layer's P | + +## Server follow-ups recorded by later layers + +| Layer | Follow-up | Why it is not in that layer | +|---|---|---| +| 100 | Reconcile the live config when a native Kiro device login finishes publishing, independent of a status request (today `/api/oauth/status` with the flowId reconciles on `done`). Closing the whole browser tab during the commit window leaves the account on disk but the running config unreconciled until the next reload. | 100 is GUI-only; its module-scoped finalizer covers component unmount and navigation while the tab stays open. Pre-existing 060 behaviour. | diff --git a/devlog/_plan/260926_kiro_lb_parity2/100_gui_device_login_and_skip_reason.md b/devlog/_plan/260926_kiro_lb_parity2/100_gui_device_login_and_skip_reason.md new file mode 100644 index 00000000000..7b6b968df0d --- /dev/null +++ b/devlog/_plan/260926_kiro_lb_parity2/100_gui_device_login_and_skip_reason.md @@ -0,0 +1,244 @@ +# 100 — Dashboard native Kiro login and auto-selection chip + +Work phase wp11. Branch `codex/kiro-lb2-100-gui-device-login` from dev `e69347a202`. This is the single +executable plan; later sections refine it and are folded in, never override silently. + +## Why + +081 records two dashboard rows where kiro-lb still leads: a browser device-login flow and an operator view of +why an account is skipped. The server side for both already landed — native device login routes (060, +`src/server/management/oauth-account-routes.ts` start ~239, status ~330-342, cancel ~304-314; +`src/oauth/kiro-device-login.ts` public `KiroDeviceView`) and per-row `autoSelectable` / `skipReason` +(070, roster ~407). This unit is **`gui/` plus docs only**; no `src/` change, so the core–Lab boundary and +the server contracts are untouched. + +## Decisions + +- **D100-1 Entry point.** For `item.name === "kiro"`, both the "Add account" button + (`gui/src/components/provider-workspace/ProviderAuthPanel.tsx` ~624) **and** the logged-out "Login" button + (~473) open a chooser: Builder ID, Google, GitHub, and "Kiro CLI". The Kiro CLI option calls the unchanged + `onLogin(name, addAccount)` so the existing POST without `method` stays byte-identical. Decision taken by + main: the start route never requires an existing account, so first sign-in gets the same choice. The + add-provider modal flow (`gui/src/components/use-add-provider-oauth.ts`) is unchanged. Non-Kiro providers + render exactly as before. +- **D100-2 Hook.** New `gui/src/components/use-kiro-device-login.ts` (modelled on + `use-main-device-reauth.ts`). Phases `idle | starting | pending | done | expired | failed | cancelled`. + Reads only `KiroDeviceView` fields (flowId, method, state, userCode, verificationUri, + verificationUriComplete, expiresAt, warning/error codes); ignores anything else. Polls + `GET /api/oauth/status?provider=kiro&flowId=` every 2 s (server paces upstream via `nextPollAt` and + `slow_down`). Stops on any non-pending state; 404 → expired; `expiresAt` passing → expired locally. + Responses for a replaced or cancelled flowId are ignored. On `done` it calls the existing + `onLoginSettled` roster refresh. Timers are cleared on unmount. +- **D100-3 Dialog.** New `gui/src/components/KiroDeviceLoginDialog.tsx` using `useModalDialog` + (role=dialog, aria-modal, labelled, focus trap, ESC, focus return). Shows the user code large with + copy feedback (`useCopyFeedback`), an "Open verification page" link to + `verificationUriComplete ?? verificationUri` rendered **only if it parses as https** (server also enforces), + a `role="status" aria-live="polite"` line, a duplicate warning for `duplicate_profile_arn`, a notice for + `manual_review_required`, and a start error for 400/409. Start controls are disabled while `starting`. + ESC / Cancel cancels the pending flow and closes. +- **D100-4 Cancel and ordering.** Cancel sends `POST /api/oauth/login/cancel {provider:"kiro", flowId}` + directly (not `cancelOAuthLogin`, whose provider-keyed call without flowId only stops kiro-cli). The + native start runs inside `afterOAuthCancellation(apiBase, "kiro", …)` so an in-flight kiro-cli cancel + settles first. The chooser is disabled while `busy === "kiro"`; Add account / Login are disabled while + the dialog is pending. The native flow never sets `busy`, so the provider-wide polling loop in + `use-providers-oauth.ts` is unchanged. A cancel answered with `done` (commit already happened) shows + success and refreshes. +- **D100-5 Row chip.** `OAuthAccount` (`gui/src/hooks/useProviderAccountPools.ts` ~17) gains optional + `autoSelectable?: boolean` and `skipReason?: "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted"`. + Label logic lives in new `gui/src/kiro-account-selection.ts`. The chip renders in the row badges only + for Kiro rows with `autoSelectable === false`; `needs_reauth` adds nothing (reauth badge exists); + `cooldown` adds nothing when the cooldown health badge already shows; `suspended` and + `quota_exhausted` show "Not auto-selected: …". Unknown reasons fall back to the generic label. Tray + unchanged. +- **D100-6 i18n.** About 25 keys under a `kiroLogin.*` / `kiroSelection.*` namespace in all 10 locale + files (de, en, fr, ja, ko, ru, tr, vi, zh-TW, zh); reuse `common.cancel` etc. where they fit. No + hardcoded UI text (`gui/AGENTS.md`). `gui/tests/locale-parity.test.ts` and `lint:i18n` enforce parity. +- **D100-7 CSS.** `gui/src/styles.css` is 2957/2958 capped: new rules go to + `gui/src/styles/kiro-device-login.css`, imported from the dialog/chooser module (precedent + `styles/login-url-block.css`). `Models.tsx` untouched. + +## Change map + +| Path | Change | +|---|---| +| `gui/src/components/use-kiro-device-login.ts` | NEW hook (D100-2, D100-4) | +| `gui/src/components/KiroDeviceLoginDialog.tsx` | NEW dialog (D100-3) | +| `gui/src/components/KiroLoginChooser.tsx` | NEW chooser (D100-1) | +| `gui/src/kiro-account-selection.ts` | NEW chip label helper (D100-5) | +| `gui/src/styles/kiro-device-login.css` | NEW styles (D100-7) | +| `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` | MODIFY: Kiro buttons open the chooser; chip in row badges | +| `gui/src/hooks/useProviderAccountPools.ts` | MODIFY: two optional fields | +| wiring (`Providers.tsx` / provider workspace props) | MODIFY only as needed to pass `apiBase` and `onLoginSettled` | +| `gui/src/i18n/*` (10 locales) | MODIFY: new keys | +| `gui/tests/kiro-device-login.test.tsx` | NEW | +| `gui/tests/kiro-account-skip-reason.test.tsx` | NEW | +| `docs-site/src/content/docs/guides/providers.md` (~204, ~417-430) | MODIFY: dashboard choices | +| `docs-site/src/content/docs/reference/cli/providers-accounts.md` (~394) | MODIFY: one dashboard sentence | +| `structure/gui-and-management-api.md` | MODIFY: new GUI modules and the flowId-scoped cancel rule | +| `devlog/_plan/260926_kiro_lb_parity2/081_head_to_head_result.md` | MODIFY after merge evidence: drop the two dashboard rows from "still leads" | + +## Tests + +`gui/tests/kiro-device-login.test.tsx` (happy-dom, pattern of `provider-auth-device-code-copy.test.tsx`): +start → pending shows code and https link; non-https link is not rendered as a link; done triggers the settle +refresh; 404 → expired; cancel posts the flowId; a late reply for a replaced flow is ignored; duplicate +warning shows; unknown fields are ignored; the Kiro CLI option still POSTs without `method`; a non-Kiro +provider's Add account does not open the chooser. +`gui/tests/kiro-account-skip-reason.test.tsx`: chip only for Kiro rows with `autoSelectable === false`, +suppressed for `needs_reauth`, suppressed for `cooldown` when the cooldown badge shows, shown for +`suspended` / `quota_exhausted`. +Re-run: `main-device-reauth*.test.tsx`, `add-provider-oauth-url-leak.test.tsx`, +`provider-auth-login-copy-link.test.tsx`, `locale-parity.test.ts`. + +Validation: `cd gui && bun test --isolate tests && bun run lint && bun run lint:i18n && bun run build`; +root `bun run typecheck`, `bun run privacy:scan`, `bun run structure:check`, +`bun test tests/ci-workflows/file-size-ratchet.test.ts` (or the ratchet file that exists). + +## Screenshot (PR requirement for `gui/`) + +No live calls: isolated `OPENCODEX_HOME` proxy + scratch Bun shim in `.tmp/` that forwards everything +except the native-method login POST, the flowId status call and cancel (canned pending/done views) and +rewrites `/api/oauth/accounts?provider=kiro` so one row has `autoSelectable:false, +skipReason:"quota_exhausted"`. Vite with `OPENCODEX_PROXY_TARGET` → shim; capture chooser, dialog, chip; +upload to `pr-assets` and link by commit SHA. + +## Risks + +- Social and Builder ID device replies are fixture-verified only; unexpected states end in a visible failed + state with nothing written. +- flowId is bound to the dashboard session; a reload loses the dialog and the server keeps the orphan flow + until expiry (≤15 min). Acceptable; documented. +- Native login is add-only (060): it never replaces an existing account. + +## P reflection amendments (authoritative; they replace any conflicting text above) + +Verified read-only by the architect at `e69347a202`. + +1. **Row type.** Add `autoSelectable?` / `skipReason?` to `OAuthAccountRow` + (`gui/src/components/provider-workspace/types.ts` ~55), which is what `ProviderAuthPanel` renders. The + `OAuthAccount` change is optional. +2. **Settle wiring.** Extract the completion block in `gui/src/pages/use-providers-oauth.ts` ~173-196 + (`onLoginSettled`, `fetchAccountSets`, same-account/ok notice, `fetchConfig`, `fetchProviderQuotas(true)`, + `bumpModelsRefresh`) into `settleProviderLogin(provider, {addAccount})` with **identical** behaviour for + the existing kiro-cli/other-provider path. Expose it as an **optional** `onNativeLoginSettled` on + `ProviderAuthHandlers` (types.ts ~83; optional because 8 tests build `authHandlers` literally) and wire it + in `Providers.tsx` (~644). First login needs `fetchConfig` because the server upserts `kiro` into config, + and the panel flips to logged-in only when `accounts.length > 0`. +3. **404 is not "expired".** Status returns a terminal view once and then forgets the flow; a lost `done` + reply becomes 404. Map 404 (status or cancel) to a neutral "login ended" state and **always run the settle + refresh**. "Expired" only for an `expired` state or the local `expiresAt` check. No per-tick abort + under ~45 s (upstream device calls take up to 20 s). +4. **Close during start.** Use a generation guard: if the dialog closed or restarted while `starting`, + immediately POST cancel with the returned `flowId`. Orphans otherwise stay pending up to the device + lifetime (≤15 min) and count toward the 4-pending-flow limit (409). The Risks line reads "≤15 min". +5. **Unmount.** On unmount stop polling and send a best-effort cancel; if the cancel answers `done`, run the + settle refresh. +6. **ESC / dialog.** `useModalDialog` drives a native ``; add + `onCancel={e => { e.preventDefault(); cancelFlow(); }}` (precedent `dashboard-dialogs.tsx` ~35). + `triggerRef` is the Add account / Login button. The chooser is step one of the same dialog. +7. **Route shapes.** Start `POST /api/oauth/login {provider:"kiro", method:"builder-id"|"google"|"github"}` + (hyphen). Never send `accountId` or `reauth` (400 `native_login_is_add_only`). Errors `{error}`: 400 + invalid method; 409 could-not-start / flow limit / namespace collision. Status: bare `KiroDeviceView` + (warning codes only, no error field); unknown/foreign/past-deadline → 404 `{error:"unknown login flow"}`. + Cancel `POST /api/oauth/login/cancel {provider:"kiro", flowId}` → view (`cancelled`, `done` if + committed, or the existing terminal state); forgotten → 404. Anchors: start 225-251, status 335-352, + cancel 304-315. +8. **CSS.** Import `./styles/kiro-device-login.css` from the component module (precedent + `AccountAutoSwitchControl.tsx` ~5), **not** from `styles.css`. Semantic design-system tokens only (no + hex/rgb), reuse `.btn` / `.badge`; the chip uses `.badge-amber`. +9. **i18n.** Each locale is compile-checked `Record`; `sync-locale-keys.mjs` skips `vi` (add + by hand); `locale-parity.test.ts` fails if a zh-TW value equals English; `kiro-account-selection.ts` + returns `TKey`s, never strings. +10. **Other Kiro entry points stay on kiro-cli** (tested): `CatalogAccountRow.tsx` ~70/88/107, + `AddProviderModal.tsx` ~238, add-intent at `Providers.tsx` ~562. In the chooser, Kiro CLI is listed + first (one-click import of an existing kiro-cli session stays the fastest path). +11. **Tests.** happy-dom window with manual globals, `globalThis.fetch` replaced/restored, `showModal` + stubbed as in `action-dialogs.test.ts`; the poll interval is injectable. Add: logged-out Login → + chooser → `done` → settle handler (config refresh); lost `done` then 404 → neutral state + settle; + close while `starting` → cancel with returned flowId. +12. **Screenshot shim** also forwards `/opencodex-session` and `/healthz` and passes cookies unchanged. + +Validation (final): +``` +cd gui && bun run lint:i18n && bun run lint && bun run build && bun test --isolate tests +bun run typecheck +bun test tests/ci-workflows/file-size-ratchet.test.ts tests/ci-workflows/structure-ssot.test.ts +bun run structure:check && bun run privacy:scan +cd docs-site && bun run build +``` + +## A audit amendments (round 1; authoritative over everything above) + +- **A1 (High) Provisional done on cancel.** `cancelKiroDeviceLogin` returns a synthetic `{state:"done"}` while + the flow is still `pending` once `commitAccepted` is set (`src/oauth/kiro-device-login.ts` ~303); the + write and `publishConfig` can still fail and roll back (~266-278). The hook treats a cancel reply of + `done` as **provisional**: it keeps polling status for that flowId (the flow is not forgotten on this path) + and shows success only on a status reply with `state:"done"`; `failed` shows failure; a later 404 goes to + the neutral "login ended" path (A3). Only the terminal status reply triggers the success settle. +- **A2 Settle split.** Do **not** move the reauth identity checks, baseline comparison, roster seed or + generation guard out of the existing polling loop in `use-providers-oauth.ts`; that path stays + behaviourally identical. Extract only the shared refresh work into + `refreshAfterProviderLogin(provider)` (`onLoginSettled`, `fetchAccountSets`, `fetchConfig`, + `fetchProviderQuotas(true)`, `bumpModelsRefresh`); notices stay with the caller. The optional handler + becomes `onNativeLoginSettled(provider, outcome: "added" | "ended")`: `added` refreshes and shows the + existing added/ok notice; `ended` refreshes without a success notice. +- **A3 404.** A 404 on status or cancel → refresh the roster (`outcome:"ended"`) and show the neutral + "login ended — check the account list" state. Never a success notice. +- **A4 Verification destination.** The GUI renders a clickable link (`target="_blank"`, + `rel="noopener noreferrer"`) only for https URLs whose host is `kiro.dev`, `amazonaws.com`, + `aws.amazon.com` or `awsapps.com`, or a subdomain of one of those (exact label-boundary suffix + match, no userinfo, no port other than default). Any other URL is shown as plain copyable text with a + "unexpected verification host" notice and no link. Pure helper `kiroVerificationLink(url)` in + `gui/src/kiro-account-selection.ts` (renamed module is fine: `gui/src/kiro-device-login-helpers.ts`), + unit-tested with spoofing cases (`kiro.dev.evil.com`, `evilkiro.dev`, userinfo, http, control chars). +- **A5 Race tests (controlled promises).** Add: cancel → provisional `done` → status `done` → success; + cancel → provisional `done` → status `failed` → failure; two rapid starts (second ignored or first + cancelled, never two pending flows shown); cancel while a status reply is in flight (late pending reply + ignored); unmount with an unresolved start → cancel with the returned flowId; ESC cancels; focus returns to + the trigger on close. +- **A6 Docs.** Also rewrite `docs-site/src/content/docs/guides/providers.md` ~442 so Add account + distinguishes the native device choices (add-only, no kiro-cli sign-out) from the Kiro CLI choice. + +## A audit amendments (round 2; authoritative over everything above) + +- **A7 (High) Finalizer survives unmount.** The live config is reconciled only when a status request observes + `done` (`oauth-account-routes.ts` ~340-346). The GUI therefore owns a **module-scoped finalizer** + `finalizeKiroDeviceFlow(apiBase, flowId)` in `gui/src/kiro-device-login-finalizer.ts`: a de-duplicated + (one per flowId) loop that GETs status every 2 s until a terminal state or 404, bounded by the flow's + `expiresAt` plus 60 s, independent of any React component. It resolves to `"added" | "ended" | "failed"` + and emits the result to subscribers (`subscribeKiroDeviceFinal`). The hook hands the flow to the + finalizer whenever it stops watching a flow whose commit may be in progress: after a provisional `done` + from cancel, and on unmount/close with an unresolved start or a pending flow (after sending the cancel). + The Providers page subscribes and runs `onNativeLoginSettled` when mounted; if the page is gone, the + finalizer's status read alone is enough for the server to reconcile. Residual risk: closing the whole + browser tab during the commit window skips reconciliation until the next config reload; this is recorded + as a server follow-up candidate in 000 and in the PR. +- **A8 In-flight terminal replies win.** The hook (and finalizer) processes a terminal reply from a status + request already in flight even after Cancel; a later 404 still takes the neutral path (A3). A1's promise + becomes: success is shown only after a terminal `done` status reply is observed. +- **A9 Settle split at the guard.** Two helpers: `reloadAccountsAfterLogin(provider)` (awaited + `fetchAccountSets`) and `refreshDerivedAfterLogin()` (`fetchConfig`, `fetchProviderQuotas(true)`, + `bumpModelsRefresh`). The existing loop keeps its generation/mounted guard **between** them, keeps its + roster seed and notice calculation inline, and is otherwise unchanged. The native path calls + `onLoginSettled` → `reloadAccountsAfterLogin` → mounted check → notice (added only) → + `refreshDerivedAfterLogin`. +- **A10 Exact hosts.** `kiroVerificationLink(url)` returns a link only for https URLs, no userinfo, default + port, whose host is exactly `device.sso.us-east-1.amazonaws.com` (Builder ID) or exactly `kiro.dev` or a + label-boundary subdomain of `kiro.dev` (Kiro-owned; social). Everything else, including other + `amazonaws.com` hosts and any `awsapps.com` portal (user-creatable), is shown as plain copyable text + with the unexpected-host notice. Tests include `evil.s3.amazonaws.com`, `x.awsapps.com`, + `kiro.dev.evil.com`, `evilkiro.dev`, userinfo, explicit port, http, control characters. + +## A audit amendments (round 3; GO-WITH-FIXES folded; final) + +- **A11** `onLoginSettled` (Accounts-tab reveal + models notice) runs **only** for a confirmed `added`. + `ended` runs `reloadAccountsAfterLogin` and shows the neutral message; it never opens the models notice. +- **A12** `onNativeLoginSettled(provider, outcome: "added" | "ended" | "failed")`. `failed` reloads accounts + and shows the existing `prov.loginError`-style failure notice through `notify(..., false)`, so a failure + finalized after the dialog unmounted is still visible. Tested through the finalizer subscriber path. +- **A13** "Terminal replies win" holds across the hook and the finalizer: once `done` or `failed` is observed + for a flowId, a later 404 cannot replace it. The whole-tab-close residual is recorded in `000_plan.md` + (server follow-up table). + +Audit record: round 1 NO-GO (1 High, 4 Medium, 1 Low) → round 2 NO-GO (1 High, 3 Medium) → round 3 +GO-WITH-FIXES (0 High, 2 Medium, 1 Low; folded as A11-A13). diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index dcae22680b3..1d51fc11d2a 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -201,7 +201,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan coding models. Defaults to the stable `kimi-for-coding` alias (currently K2.8 Preview): 1M-token context window, adjustable `low`/`high`/`max` thinking (default `max`), text + image input. Retired `kimi-k2.x` selections are migrated to the alias on upgrade. | | `kimi-responses` | `openai-responses` | `https://api.kimi.com/coding/v1` | Same Kimi account login (reuses the `kimi` OAuth credential) over the OpenAI Responses wire. Same model roster and capabilities as `kimi`; thinking content stays encrypted server-side, tool calls and results stay visible. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | The dashboard Login and Add account buttons offer Builder ID, Google, GitHub device login, or Kiro CLI. Native device login adds an account without signing `kiro-cli` out. The Kiro CLI choice imports or starts a CLI session; its Add account workflow temporarily switches the CLI account and restores it on cancellation or failure. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | Browser consent and key exchange use `https://www.orcarouter.ai` with S256 PKCE. The returned user-owned `sk-orca-…` API key is stored in the existing credential store and reused until revoked. | @@ -414,7 +414,8 @@ report credit usage; an explicit zero means it reported no spend. ### Kiro credential import -Kiro login expects the Kiro CLI: on Unix, install it with `curl -fsSL https://cli.kiro.dev/install | bash`; +The dashboard offers native Builder ID, Google, and GitHub device login without Kiro CLI. +The Kiro CLI choice and `ocx login kiro` import path use the CLI: on Unix, install it with `curl -fsSL https://cli.kiro.dev/install | bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1' | iex`; then sign in with `kiro-cli login`. Without a `kiro-cli` session, `ocx login kiro` falls back to a pasted access token or the `KIRO_ACCESS_TOKEN` environment variable. @@ -439,17 +440,19 @@ After a successful import, opencodex persists the imported credential to Keep these variables and the selected database private. Do not attach database files or raw login diagnostics to bug reports. -**Add account** is a separate write workflow: it snapshots the current session, logs `kiro-cli` out, +For the **Kiro CLI** choice, **Add account** is a separate write workflow: it snapshots the current session, logs `kiro-cli` out, and imports the fresh browser login. If the login is cancelled or fails, including while OpenCodex persists the credential, rollback replaces the Kiro CLI database and removes its current WAL, SHM, and journal sidecars before publishing the previous session snapshot. -Because that rollback is only possible from a snapshot, **Add account** refuses to sign `kiro-cli` +Because that rollback is only possible from a snapshot, the Kiro CLI **Add account** choice refuses to sign `kiro-cli` out when a session store is present but cannot be captured (unreadable file, mismatched schema, or an ambiguous token selection), when `KIROCLI_DB_PATH` / `KIRO_CLI_DB_FILE` redirect import reads away from the live CLI store, or when an existing primary CLI database has no recognized token row. Repair or remove the unreadable database under the normal `kiro-cli` data path, unset those import selectors, then retry. Signing in from a machine with no existing `kiro-cli` session is unaffected. +The native dashboard choices are add-only and do not sign out `kiro-cli`. A device dialog shows the code and verification destination. Only recognized Kiro or Builder ID hosts are opened as links; an unexpected destination is shown as copyable text for review. +The account list marks Kiro accounts excluded from automatic selection with a reason, when available. ## 3. API-key catalog diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index b193dedf4ea..e344104d868 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -391,7 +391,7 @@ With two or more Kiro accounts logged in, request-rate, confirmed monthly-quota, confirmed suspension refusals can rotate before output. Monthly exhaustion excludes only that login until reset or evidence expiry; completed service clears an older verdict. Reactive rotation remains available when proactive account preference is off. Accounts are added one at a time — -`ocx account login kiro` hands off to the Kiro CLI and appends the new account to the pool. +`ocx account login kiro` hands off to the Kiro CLI and appends the new account to the pool. In the dashboard, Login and Add account also offer Builder ID, Google, and GitHub device login alongside the Kiro CLI choice; native device login adds an account without signing the CLI out. To add an account without the Kiro CLI, use `ocx account login kiro --method builder-id`, `--method google`, or `--method github`. Open the printed verification URL, enter the user code, and wait for approval. `--no-wait` prints the flow ID; cancel it with diff --git a/gui/src/components/KiroDeviceLoginDialog.tsx b/gui/src/components/KiroDeviceLoginDialog.tsx new file mode 100644 index 00000000000..aa0c4729be1 --- /dev/null +++ b/gui/src/components/KiroDeviceLoginDialog.tsx @@ -0,0 +1,75 @@ +import { useId, type RefObject } from "react"; +import { useT } from "../i18n/shared"; +import { useModalDialog } from "../pages/dashboard-shared"; +import { kiroVerificationLink } from "../kiro-device-login-helpers"; +import { useCopyFeedback } from "./use-copy-feedback"; +import { useKiroDeviceLogin } from "./use-kiro-device-login"; +import KiroLoginChooser from "./KiroLoginChooser"; +import type { KiroFinalOutcome } from "../kiro-device-login-finalizer"; +import "../styles/kiro-device-login.css"; + +export default function KiroDeviceLoginDialog({ apiBase, triggerRef, addAccount, busy, onCli, onClose, onSettled, + pollDelay }: { + apiBase: string; + triggerRef: RefObject; + addAccount: boolean; + busy: boolean; + onCli: (addAccount: boolean) => void; + onClose: () => void; + onSettled?: (provider: string, outcome: KiroFinalOutcome) => void; + pollDelay?: (ms: number) => Promise; +}) { + const t = useT(); + const titleId = useId(); + const dialogRef = useModalDialog(true, triggerRef); + const login = useKiroDeviceLogin(apiBase, onSettled, pollDelay); + const copy = useCopyFeedback(); + const { state } = login; + const view = state.view; + const destination = view?.verificationUriComplete ?? view?.verificationUri; + const safeLink = kiroVerificationLink(destination); + const cancel = () => { void login.close(); onClose(); }; + return ( + { event.preventDefault(); cancel(); }}> +

{t("kiroLogin.title")}

+ {state.phase === "idle" && ( + <> +

{t(addAccount ? "kiroLogin.addDescription" : "kiroLogin.loginDescription")}

+ { onClose(); onCli(addAccount); }} onMethod={method => { void login.start(method); }} /> + + )} + {state.phase === "starting" &&

{t("kiroLogin.starting")}

} + {view && (state.phase === "pending" || state.phase === "done" || state.phase === "failed" || state.phase === "expired" || state.phase === "ended") && ( + <> + {view.userCode && ( +
+ {view.userCode} + +
+ )} + {destination && (safeLink + ? {t("kiroLogin.openVerification")} + : <>

{destination}

{t("kiroLogin.unexpectedHost")}

)} + {view.warning === "duplicate_profile_arn" &&

{t("kiroLogin.duplicate")}

} + {view.warning === "manual_review_required" &&

{t("kiroLogin.manualReview")}

} + + )} +

+ {state.phase === "pending" && t("kiroLogin.pending")} + {state.phase === "done" && t("kiroLogin.done")} + {state.phase === "expired" && t("kiroLogin.expired")} + {state.phase === "ended" && t("kiroLogin.ended")} + {state.phase === "failed" && t(state.error === "start" ? "kiroLogin.startFailed" : "kiroLogin.failed")} +

+
+ {(state.phase === "failed" || state.phase === "expired" || state.phase === "ended") && ( + + )} + +
+
+ ); +} diff --git a/gui/src/components/KiroLoginChooser.tsx b/gui/src/components/KiroLoginChooser.tsx new file mode 100644 index 00000000000..586fe110583 --- /dev/null +++ b/gui/src/components/KiroLoginChooser.tsx @@ -0,0 +1,18 @@ +import { useT } from "../i18n/shared"; +import type { KiroDeviceMethod } from "../kiro-device-login-helpers"; + +export default function KiroLoginChooser({ disabled, onCli, onMethod }: { + disabled: boolean; + onCli: () => void; + onMethod: (method: KiroDeviceMethod) => void; +}) { + const t = useT(); + return ( +
+ + + + +
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 6db3c02046e..61debe1abb9 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -27,6 +27,8 @@ import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool import { useGrokResetCoupons } from "../../hooks/useGrokResetCoupons"; import { useAnthropicResetGrants } from "../../hooks/useAnthropicResetGrants"; import { Switch } from "../../ui"; +import { kiroSkipReasonKey } from "../../kiro-device-login-helpers"; +import KiroDeviceLoginDialog from "../KiroDeviceLoginDialog"; import type { AccountLoadState, OAuthAccountRow, @@ -197,6 +199,13 @@ export default function ProviderAuthPanel({ }) { const t = useT(); const [addingKey, setAddingKey] = useState(false); + const [kiroChooser, setKiroChooser] = useState<{ addAccount: boolean } | null>(null); + const kiroLoginTriggerRef = useRef(null); + const kiroAddTriggerRef = useRef(null); + const openLogin = (addAccount: boolean) => { + if (item.name === "kiro") setKiroChooser({ addAccount }); + else void authHandlers?.onLogin(item.name, addAccount); + }; const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); const [importBusy, setImportBusy] = useState(false); @@ -470,7 +479,7 @@ export default function ProviderAuthPanel({ {loggedIn ? ( ) : ( - @@ -556,6 +565,7 @@ export default function ProviderAuthPanel({ {healthLabel} )} {showReauth && !healthLabel && {t("pws.reauth")}} + {kiroSkipReasonKey(account, item.name) && {t(kiroSkipReasonKey(account, item.name)!)}} {account.active && {t("prov.accountActive")}} {switching && {t("pws.accountSwitching")}} @@ -623,8 +633,8 @@ export default function ProviderAuthPanel({ )} {loggedIn && (
- {canRefreshQuota && ( @@ -648,6 +658,12 @@ export default function ProviderAuthPanel({ )} )} + {item.name === "kiro" && kiroChooser && ( + setKiroChooser(null)} + onCli={addAccount => { void authHandlers.onLogin(item.name, addAccount); }} + onSettled={authHandlers.onNativeLoginSettled} /> + )} {isKeyAuth && ( <> diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 6f913e1bcf0..8f95133b778 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -58,6 +58,8 @@ export type OAuthAccountRow = AccountQuotaReading & { email?: string; active: boolean; needsReauth?: boolean; + autoSelectable?: boolean; + skipReason?: "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted"; health?: { status: OAuthAccountHealthStatus; reason?: string; until?: string }; healthLabel?: string; healthSummary?: string; @@ -82,6 +84,7 @@ export type AccountLoadState = "idle" | "loading" | "ready" | "error"; export interface ProviderAuthHandlers { onLogin: (provider: string, addAccount?: boolean) => void | Promise; + onNativeLoginSettled?: (provider: string, outcome: "added" | "ended" | "failed") => void | Promise; onCancelLogin?: (provider: string) => void; onLogout: (provider: string) => void | Promise; onReauth: (provider: string, accountId?: string) => void | Promise; diff --git a/gui/src/components/use-kiro-device-login.ts b/gui/src/components/use-kiro-device-login.ts new file mode 100644 index 00000000000..1e8d2c92379 --- /dev/null +++ b/gui/src/components/use-kiro-device-login.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { afterOAuthCancellation } from "../oauth-cancellation-barrier"; +import { finalizeKiroDeviceFlow, observeKiroDeviceFinal, type KiroFinalOutcome } from "../kiro-device-login-finalizer"; +import { parseKiroDeviceView, type KiroDeviceMethod, type KiroDeviceView } from "../kiro-device-login-helpers"; + +type Phase = "idle" | "starting" | "pending" | "done" | "expired" | "failed" | "cancelled" | "ended"; +export type KiroLoginState = { phase: Phase; view?: KiroDeviceView; error?: "start" | "network" | "invalid" }; +type Session = { closed: boolean; view?: KiroDeviceView; inFlight?: Promise; + waitController?: AbortController; terminal?: KiroFinalOutcome }; +const wait = (ms: number, signal: AbortSignal) => new Promise(resolve => { + if (signal.aborted) { resolve(); return; } + const timer = setTimeout(() => { signal.removeEventListener("abort", stop); resolve(); }, ms); + const stop = () => { clearTimeout(timer); signal.removeEventListener("abort", stop); resolve(); }; + signal.addEventListener("abort", stop, { once: true }); +}); +const CLOSED = Symbol("closed"); +/** The awaited value, or CLOSED when the session closed while it was pending (a late reply belongs to the finalizer). */ +const unlessClosed = (session: Session, value: Promise): Promise => + value.then(result => (session.closed ? CLOSED : result)); + +export function useKiroDeviceLogin(apiBase: string, onSettled?: (provider: string, outcome: KiroFinalOutcome) => void, + pollDelay: (ms: number, signal: AbortSignal) => Promise = wait) { + const [state, setState] = useState({ phase: "idle" }); + const sessionRef = useRef(null); + const mountedRef = useRef(true); + const settledRef = useRef(onSettled); + useEffect(() => { settledRef.current = onSettled; }, [onSettled]); + + const cancelServer = useCallback(async (view: KiroDeviceView): Promise => { + try { + return await fetch(`${apiBase}/api/oauth/login/cancel`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "kiro", flowId: view.flowId }), keepalive: true, + }); + } catch { return null; } + }, [apiBase]); + + const handoff = useCallback((session: Session) => { + const view = session.view; + if (view && !session.terminal) void finalizeKiroDeviceFlow(apiBase, view.flowId, view.expiresAt, session.inFlight); + }, [apiBase]); + + const close = useCallback(async () => { + const session = sessionRef.current; + if (!session || session.closed) return; + session.closed = true; + sessionRef.current = null; + session.waitController?.abort(); + if (mountedRef.current) setState({ phase: "cancelled" }); + if (!session.view || session.terminal) return; + // cancelServer dispatches fetch before its first await. Status reconciliation + // must start only after that POST has been sent, without waiting for its reply. + const cancellation = cancelServer(session.view); + handoff(session); + const response = await cancellation; + const reply = response?.ok ? parseKiroDeviceView(await response.json().catch(() => null)) : null; + if (reply) observeKiroDeviceFinal(apiBase, session.view.flowId, reply, "cancel"); + }, [apiBase, cancelServer, handoff]); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; void close(); }; + }, [close]); + + const start = useCallback(async (method: KiroDeviceMethod) => { + if (sessionRef.current) return; + const session: Session = { closed: false }; + sessionRef.current = session; + setState({ phase: "starting" }); + let response: Response | undefined; + try { + response = await afterOAuthCancellation(apiBase, "kiro", () => fetch(`${apiBase}/api/oauth/login`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: "kiro", method }), + })); + } catch { + if (!session.closed && mountedRef.current) setState({ phase: "failed", error: "network" }); + if (sessionRef.current === session) sessionRef.current = null; + return; + } + const view = response.ok ? parseKiroDeviceView(await response.json().catch(() => null)) : null; + if (!view || view.method !== method) { + if (!session.closed && mountedRef.current) setState({ phase: "failed", error: response.ok ? "invalid" : "start" }); + if (sessionRef.current === session) sessionRef.current = null; + return; + } + session.view = view; + if (session.closed) { + const cancellation = cancelServer(view); + handoff(session); + const cancelled = await cancellation; + const reply = cancelled?.ok ? parseKiroDeviceView(await cancelled.json().catch(() => null)) : null; + if (reply) observeKiroDeviceFinal(apiBase, view.flowId, reply, "cancel"); + return; + } + if (view.state !== "pending") { + if (view.state === "done") settledRef.current?.("kiro", "added"); + else if (view.state === "failed") settledRef.current?.("kiro", "failed"); + if (mountedRef.current) setState({ phase: view.state, view }); + session.terminal = view.state === "done" ? "added" : view.state === "failed" ? "failed" : "ended"; + sessionRef.current = null; + return; + } + setState({ phase: "pending", view }); + while (!session.closed && !session.terminal) { + const waitController = new AbortController(); + session.waitController = waitController; + await pollDelay(2_000, waitController.signal); + session.waitController = undefined; + if (session.closed || session.terminal) break; + if (session.view?.expiresAt && Date.now() >= session.view.expiresAt) { + session.terminal = "ended"; + if (mountedRef.current) setState({ phase: "expired", view: session.view }); + break; + } + const flowId = view.flowId; + const request = fetch(`${apiBase}/api/oauth/status?provider=kiro&flowId=${encodeURIComponent(flowId)}`).catch(() => null); + session.inFlight = request.then(response => response?.clone() ?? null); + const status = await unlessClosed(session, request); + if (status === CLOSED) break; + if (status?.status === 404) { + session.inFlight = undefined; + session.terminal = "ended"; + settledRef.current?.("kiro", "ended"); + setState({ phase: "ended", view: session.view }); + break; + } + const body = status?.ok ? await unlessClosed(session, status.json().catch(() => null)) : null; + if (body === CLOSED) break; + const next = body === null ? null : parseKiroDeviceView(body); + session.inFlight = undefined; + if (!next || next.flowId !== flowId) continue; + session.view = next; + if (next.state === "pending") { setState({ phase: "pending", view: next }); continue; } + session.terminal = next.state === "done" ? "added" : next.state === "failed" ? "failed" : "ended"; + if (next.state === "done" || next.state === "failed") settledRef.current?.("kiro", session.terminal); + setState({ phase: next.state, view: next }); + break; + } + if (sessionRef.current === session) sessionRef.current = null; + }, [apiBase, cancelServer, handoff, pollDelay]); + + const reset = useCallback(() => { if (!sessionRef.current) setState({ phase: "idle" }); }, []); + return { state, start, close, reset }; +} diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index 2a1e7234cdc..8cfec14fd52 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -20,6 +20,8 @@ export interface OAuthAccount extends AccountQuotaReading { email?: string; active: boolean; needsReauth?: boolean; + autoSelectable?: boolean; + skipReason?: "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted"; expiresAt?: number; health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string }; healthLabel?: string; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 34266a80d56..65c50730417 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,33 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "kiroLogin.title": "Bei Kiro anmelden", + "kiroLogin.chooseMethod": "Anmeldemethode wählen", + "kiroLogin.cli": "Mit Kiro CLI anmelden", + "kiroLogin.builderId": "Mit Builder ID anmelden", + "kiroLogin.google": "Mit Google anmelden", + "kiroLogin.github": "Mit GitHub anmelden", + "kiroLogin.addDescription": "Kiro-Konto per Geräteanmeldung hinzufügen oder eine Kiro-CLI-Sitzung importieren.", + "kiroLogin.loginDescription": "Per Gerät anmelden oder die Kiro-CLI-Sitzung importieren.", + "kiroLogin.starting": "Geräteanmeldung wird gestartet…", + "kiroLogin.pending": "Code auf der Bestätigungsseite eingeben und hier warten.", + "kiroLogin.done": "Kiro-Konto hinzugefügt.", + "kiroLogin.expired": "Geräteanmeldung abgelaufen. Erneut versuchen.", + "kiroLogin.ended": "Anmeldung beendet. Kontoliste prüfen.", + "kiroLogin.failed": "Kiro-Anmeldung fehlgeschlagen.", + "kiroLogin.startFailed": "Kiro-Anmeldung konnte nicht gestartet werden. Erneut versuchen.", + "kiroLogin.tryAgain": "Erneut versuchen", + "kiroLogin.copyCode": "Code kopieren", + "kiroLogin.copied": "Code kopiert", + "kiroLogin.copyUnavailable": "Kopieren nicht möglich", + "kiroLogin.openVerification": "Bestätigungsseite öffnen", + "kiroLogin.unexpectedHost": "Unerwarteter Bestätigungshost. Adresse vor dem Kopieren prüfen.", + "kiroLogin.duplicate": "Dieses Kiro-Profil ist bereits in der Kontoliste.", + "kiroLogin.manualReview": "Für dieses Konto kann eine manuelle Prüfung nötig sein.", + "kiroSelection.suspended": "Nicht automatisch ausgewählt: gesperrt", + "kiroSelection.quotaExhausted": "Nicht automatisch ausgewählt: Kontingent erschöpft", + "kiroSelection.cooldown": "Nicht automatisch ausgewählt: Abkühlzeit", + "kiroSelection.generic": "Nicht automatisch ausgewählt", "tray.updated": "Aktualisiert {time} · alle 60s", "tray.today": "Heute", "tray.input": "Eingabe", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ad00e8ad053..ce4b09b4370 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,33 @@ * `{var}` are plain interpolations. */ export const en = { + "kiroLogin.title": "Sign in to Kiro", + "kiroLogin.chooseMethod": "Choose a sign-in method", + "kiroLogin.cli": "Kiro CLI", + "kiroLogin.builderId": "Builder ID", + "kiroLogin.google": "Google", + "kiroLogin.github": "GitHub", + "kiroLogin.addDescription": "Add a Kiro account with a device sign-in, or import a Kiro CLI session.", + "kiroLogin.loginDescription": "Sign in with a device, or import your Kiro CLI session.", + "kiroLogin.starting": "Starting device sign-in…", + "kiroLogin.pending": "Enter this code on the verification page, then wait here.", + "kiroLogin.done": "Kiro account added.", + "kiroLogin.expired": "Device sign-in expired. Try again.", + "kiroLogin.ended": "Login ended. Check the account list.", + "kiroLogin.failed": "Kiro sign-in failed.", + "kiroLogin.startFailed": "Could not start Kiro sign-in. Try again.", + "kiroLogin.tryAgain": "Try again", + "kiroLogin.copyCode": "Copy code", + "kiroLogin.copied": "Code copied", + "kiroLogin.copyUnavailable": "Copy unavailable", + "kiroLogin.openVerification": "Open verification page", + "kiroLogin.unexpectedHost": "Unexpected verification host. Check the address before copying it.", + "kiroLogin.duplicate": "This Kiro profile is already in the account list.", + "kiroLogin.manualReview": "Manual review may be needed for this account.", + "kiroSelection.suspended": "Not auto-selected: suspended", + "kiroSelection.quotaExhausted": "Not auto-selected: quota exhausted", + "kiroSelection.cooldown": "Not auto-selected: cooling down", + "kiroSelection.generic": "Not auto-selected", "tray.updated": "Updated {time} · every 60s", "tray.today": "Today", "tray.input": "Input", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e8068e096f4..f600d6ef38a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,33 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "kiroLogin.title": "Se connecter à Kiro", + "kiroLogin.chooseMethod": "Choisir une méthode de connexion", + "kiroLogin.cli": "Importer avec Kiro CLI", + "kiroLogin.builderId": "Se connecter avec Builder ID", + "kiroLogin.google": "Se connecter avec Google", + "kiroLogin.github": "Se connecter avec GitHub", + "kiroLogin.addDescription": "Ajoutez un compte Kiro par appareil ou importez une session Kiro CLI.", + "kiroLogin.loginDescription": "Connectez-vous par appareil ou importez votre session Kiro CLI.", + "kiroLogin.starting": "Démarrage de la connexion par appareil…", + "kiroLogin.pending": "Saisissez ce code sur la page de vérification, puis patientez ici.", + "kiroLogin.done": "Compte Kiro ajouté.", + "kiroLogin.expired": "Connexion par appareil expirée. Réessayez.", + "kiroLogin.ended": "Connexion terminée. Consultez la liste des comptes.", + "kiroLogin.failed": "Échec de la connexion à Kiro.", + "kiroLogin.startFailed": "Impossible de démarrer la connexion à Kiro. Réessayez.", + "kiroLogin.tryAgain": "Réessayer", + "kiroLogin.copyCode": "Copier le code", + "kiroLogin.copied": "Code copié", + "kiroLogin.copyUnavailable": "Copie indisponible", + "kiroLogin.openVerification": "Ouvrir la page de vérification", + "kiroLogin.unexpectedHost": "Hôte de vérification inattendu. Vérifiez l’adresse avant de la copier.", + "kiroLogin.duplicate": "Ce profil Kiro figure déjà dans la liste des comptes.", + "kiroLogin.manualReview": "Un examen manuel peut être nécessaire pour ce compte.", + "kiroSelection.suspended": "Non sélectionné automatiquement : suspendu", + "kiroSelection.quotaExhausted": "Non sélectionné automatiquement : quota épuisé", + "kiroSelection.cooldown": "Non sélectionné automatiquement : délai de récupération", + "kiroSelection.generic": "Non sélectionné automatiquement", "tray.updated": "Mis à jour {time} · toutes les 60s", "tray.today": "Aujourd’hui", "tray.input": "Entrée", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c3c6d6eb60d..6a979cd7836 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,33 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "kiroLogin.title": "Kiro にログイン", + "kiroLogin.chooseMethod": "ログイン方法を選択", + "kiroLogin.cli": "Kiro CLI から取り込む", + "kiroLogin.builderId": "Builder ID でログイン", + "kiroLogin.google": "Google でログイン", + "kiroLogin.github": "GitHub でログイン", + "kiroLogin.addDescription": "デバイス認証で Kiro アカウントを追加するか、Kiro CLI のセッションを取り込みます。", + "kiroLogin.loginDescription": "デバイスでログインするか、Kiro CLI のセッションを取り込みます。", + "kiroLogin.starting": "デバイス認証を開始しています…", + "kiroLogin.pending": "確認ページにこのコードを入力し、ここでお待ちください。", + "kiroLogin.done": "Kiro アカウントを追加しました。", + "kiroLogin.expired": "デバイス認証の期限が切れました。再試行してください。", + "kiroLogin.ended": "ログインが終了しました。アカウント一覧を確認してください。", + "kiroLogin.failed": "Kiro へのログインに失敗しました。", + "kiroLogin.startFailed": "Kiro へのログインを開始できませんでした。再試行してください。", + "kiroLogin.tryAgain": "再試行", + "kiroLogin.copyCode": "コードをコピー", + "kiroLogin.copied": "コードをコピーしました", + "kiroLogin.copyUnavailable": "コピーできません", + "kiroLogin.openVerification": "確認ページを開く", + "kiroLogin.unexpectedHost": "予期しない確認先です。コピーする前にアドレスを確認してください。", + "kiroLogin.duplicate": "この Kiro プロフィールは既にアカウント一覧にあります。", + "kiroLogin.manualReview": "このアカウントには手動確認が必要な場合があります。", + "kiroSelection.suspended": "自動選択されません: 停止中", + "kiroSelection.quotaExhausted": "自動選択されません: 利用枠を使い切りました", + "kiroSelection.cooldown": "自動選択されません: 待機中", + "kiroSelection.generic": "自動選択されません", "tray.updated": "更新 {time} · 60秒ごと", "tray.today": "今日", "tray.input": "入力", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index aa00df58565..e99ab00cade 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,33 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "kiroLogin.title": "Kiro에 로그인", + "kiroLogin.chooseMethod": "로그인 방법 선택", + "kiroLogin.cli": "Kiro CLI에서 가져오기", + "kiroLogin.builderId": "Builder ID로 로그인", + "kiroLogin.google": "Google로 로그인", + "kiroLogin.github": "GitHub로 로그인", + "kiroLogin.addDescription": "기기 로그인으로 Kiro 계정을 추가하거나 Kiro CLI 세션을 가져옵니다.", + "kiroLogin.loginDescription": "기기로 로그인하거나 Kiro CLI 세션을 가져옵니다.", + "kiroLogin.starting": "기기 로그인을 시작하는 중…", + "kiroLogin.pending": "확인 페이지에 이 코드를 입력하고 여기서 기다리세요.", + "kiroLogin.done": "Kiro 계정이 추가되었습니다.", + "kiroLogin.expired": "기기 로그인 시간이 만료되었습니다. 다시 시도하세요.", + "kiroLogin.ended": "로그인이 종료되었습니다. 계정 목록을 확인하세요.", + "kiroLogin.failed": "Kiro 로그인에 실패했습니다.", + "kiroLogin.startFailed": "Kiro 로그인을 시작하지 못했습니다. 다시 시도하세요.", + "kiroLogin.tryAgain": "다시 시도", + "kiroLogin.copyCode": "코드 복사", + "kiroLogin.copied": "코드가 복사되었습니다", + "kiroLogin.copyUnavailable": "복사할 수 없음", + "kiroLogin.openVerification": "확인 페이지 열기", + "kiroLogin.unexpectedHost": "예상하지 못한 확인 호스트입니다. 복사하기 전에 주소를 확인하세요.", + "kiroLogin.duplicate": "이 Kiro 프로필은 이미 계정 목록에 있습니다.", + "kiroLogin.manualReview": "이 계정은 수동 검토가 필요할 수 있습니다.", + "kiroSelection.suspended": "자동 선택 제외: 일시 중지됨", + "kiroSelection.quotaExhausted": "자동 선택 제외: 할당량 소진", + "kiroSelection.cooldown": "자동 선택 제외: 대기 중", + "kiroSelection.generic": "자동 선택 제외", "tray.updated": "업데이트 {time} · 60초마다", "tray.today": "오늘", "tray.input": "입력", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 10ea91ba4f0..f6f24178696 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,33 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "kiroLogin.title": "Войти в Kiro", + "kiroLogin.chooseMethod": "Выберите способ входа", + "kiroLogin.cli": "Импортировать через Kiro CLI", + "kiroLogin.builderId": "Войти через Builder ID", + "kiroLogin.google": "Войти через Google", + "kiroLogin.github": "Войти через GitHub", + "kiroLogin.addDescription": "Добавьте учётную запись Kiro через устройство или импортируйте сеанс Kiro CLI.", + "kiroLogin.loginDescription": "Войдите через устройство или импортируйте сеанс Kiro CLI.", + "kiroLogin.starting": "Запуск входа через устройство…", + "kiroLogin.pending": "Введите этот код на странице подтверждения и подождите здесь.", + "kiroLogin.done": "Учётная запись Kiro добавлена.", + "kiroLogin.expired": "Срок входа через устройство истёк. Повторите попытку.", + "kiroLogin.ended": "Вход завершён. Проверьте список учётных записей.", + "kiroLogin.failed": "Не удалось войти в Kiro.", + "kiroLogin.startFailed": "Не удалось начать вход в Kiro. Повторите попытку.", + "kiroLogin.tryAgain": "Повторить", + "kiroLogin.copyCode": "Скопировать код", + "kiroLogin.copied": "Код скопирован", + "kiroLogin.copyUnavailable": "Копирование недоступно", + "kiroLogin.openVerification": "Открыть страницу подтверждения", + "kiroLogin.unexpectedHost": "Неожиданный адрес подтверждения. Проверьте его перед копированием.", + "kiroLogin.duplicate": "Этот профиль Kiro уже есть в списке учётных записей.", + "kiroLogin.manualReview": "Для этой учётной записи может потребоваться ручная проверка.", + "kiroSelection.suspended": "Не выбирается автоматически: приостановлена", + "kiroSelection.quotaExhausted": "Не выбирается автоматически: квота исчерпана", + "kiroSelection.cooldown": "Не выбирается автоматически: ожидание", + "kiroSelection.generic": "Не выбирается автоматически", "tray.updated": "Обновлено {time} · каждые 60с", "tray.today": "Сегодня", "tray.input": "Вход", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 59eba015435..71e82d9b404 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,33 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "kiroLogin.title": "Kiro oturumu aç", + "kiroLogin.chooseMethod": "Oturum açma yöntemi seç", + "kiroLogin.cli": "Kiro CLI ile içe aktar", + "kiroLogin.builderId": "Builder ID ile oturum aç", + "kiroLogin.google": "Google ile oturum aç", + "kiroLogin.github": "GitHub ile oturum aç", + "kiroLogin.addDescription": "Cihazla bir Kiro hesabı ekleyin veya Kiro CLI oturumunu içe aktarın.", + "kiroLogin.loginDescription": "Cihazla oturum açın veya Kiro CLI oturumunuzu içe aktarın.", + "kiroLogin.starting": "Cihaz oturumu başlatılıyor…", + "kiroLogin.pending": "Bu kodu doğrulama sayfasına girin ve burada bekleyin.", + "kiroLogin.done": "Kiro hesabı eklendi.", + "kiroLogin.expired": "Cihaz oturumunun süresi doldu. Yeniden deneyin.", + "kiroLogin.ended": "Oturum sona erdi. Hesap listesini kontrol edin.", + "kiroLogin.failed": "Kiro oturumu açılamadı.", + "kiroLogin.startFailed": "Kiro oturumu başlatılamadı. Yeniden deneyin.", + "kiroLogin.tryAgain": "Yeniden dene", + "kiroLogin.copyCode": "Kodu kopyala", + "kiroLogin.copied": "Kod kopyalandı", + "kiroLogin.copyUnavailable": "Kopyalama kullanılamıyor", + "kiroLogin.openVerification": "Doğrulama sayfasını aç", + "kiroLogin.unexpectedHost": "Beklenmeyen doğrulama sunucusu. Kopyalamadan önce adresi kontrol edin.", + "kiroLogin.duplicate": "Bu Kiro profili hesap listesinde zaten var.", + "kiroLogin.manualReview": "Bu hesap için elle inceleme gerekebilir.", + "kiroSelection.suspended": "Otomatik seçilmiyor: askıya alındı", + "kiroSelection.quotaExhausted": "Otomatik seçilmiyor: kota tükendi", + "kiroSelection.cooldown": "Otomatik seçilmiyor: bekleme süresi", + "kiroSelection.generic": "Otomatik seçilmiyor", "tray.updated": "Güncellendi {time} · her 60 sn", "tray.today": "Bugün", "tray.input": "Girdi", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index cbd565d9a78..151c3da8578 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -6,6 +6,33 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const vi: Record = { + "kiroLogin.title": "Đăng nhập Kiro", + "kiroLogin.chooseMethod": "Chọn cách đăng nhập", + "kiroLogin.cli": "Nhập từ Kiro CLI", + "kiroLogin.builderId": "Đăng nhập bằng Builder ID", + "kiroLogin.google": "Đăng nhập bằng Google", + "kiroLogin.github": "Đăng nhập bằng GitHub", + "kiroLogin.addDescription": "Thêm tài khoản Kiro bằng thiết bị hoặc nhập phiên Kiro CLI.", + "kiroLogin.loginDescription": "Đăng nhập bằng thiết bị hoặc nhập phiên Kiro CLI.", + "kiroLogin.starting": "Đang bắt đầu đăng nhập trên thiết bị…", + "kiroLogin.pending": "Nhập mã này trên trang xác minh rồi chờ tại đây.", + "kiroLogin.done": "Đã thêm tài khoản Kiro.", + "kiroLogin.expired": "Phiên đăng nhập thiết bị đã hết hạn. Hãy thử lại.", + "kiroLogin.ended": "Đăng nhập đã kết thúc. Hãy kiểm tra danh sách tài khoản.", + "kiroLogin.failed": "Đăng nhập Kiro thất bại.", + "kiroLogin.startFailed": "Không thể bắt đầu đăng nhập Kiro. Hãy thử lại.", + "kiroLogin.tryAgain": "Thử lại", + "kiroLogin.copyCode": "Sao chép mã", + "kiroLogin.copied": "Đã sao chép mã", + "kiroLogin.copyUnavailable": "Không thể sao chép", + "kiroLogin.openVerification": "Mở trang xác minh", + "kiroLogin.unexpectedHost": "Máy chủ xác minh không mong đợi. Hãy kiểm tra địa chỉ trước khi sao chép.", + "kiroLogin.duplicate": "Hồ sơ Kiro này đã có trong danh sách tài khoản.", + "kiroLogin.manualReview": "Tài khoản này có thể cần được xem xét thủ công.", + "kiroSelection.suspended": "Không tự chọn: bị tạm ngưng", + "kiroSelection.quotaExhausted": "Không tự chọn: hết hạn mức", + "kiroSelection.cooldown": "Không tự chọn: đang chờ", + "kiroSelection.generic": "Không tự chọn", "tray.updated": "Cập nhật {time} · mỗi 60 giây", "tray.today": "Hôm nay", "tray.input": "Đầu vào", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 357be1e9410..f1ac8003519 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,33 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "kiroLogin.title": "登入 Kiro", + "kiroLogin.chooseMethod": "選擇登入方式", + "kiroLogin.cli": "從 Kiro CLI 匯入", + "kiroLogin.builderId": "使用 Builder ID 登入", + "kiroLogin.google": "使用 Google 登入", + "kiroLogin.github": "使用 GitHub 登入", + "kiroLogin.addDescription": "透過裝置登入新增 Kiro 帳戶,或匯入 Kiro CLI 工作階段。", + "kiroLogin.loginDescription": "透過裝置登入,或匯入 Kiro CLI 工作階段。", + "kiroLogin.starting": "正在啟動裝置登入…", + "kiroLogin.pending": "在驗證頁面輸入此代碼,然後在此等候。", + "kiroLogin.done": "已新增 Kiro 帳戶。", + "kiroLogin.expired": "裝置登入已逾期。請再試一次。", + "kiroLogin.ended": "登入已結束。請檢查帳戶清單。", + "kiroLogin.failed": "Kiro 登入失敗。", + "kiroLogin.startFailed": "無法啟動 Kiro 登入。請再試一次。", + "kiroLogin.tryAgain": "再試一次", + "kiroLogin.copyCode": "複製代碼", + "kiroLogin.copied": "代碼已複製", + "kiroLogin.copyUnavailable": "無法複製", + "kiroLogin.openVerification": "開啟驗證頁面", + "kiroLogin.unexpectedHost": "驗證主機異常。複製前請檢查網址。", + "kiroLogin.duplicate": "此 Kiro 個人資料已在帳戶清單中。", + "kiroLogin.manualReview": "此帳戶可能需要人工檢查。", + "kiroSelection.suspended": "不會自動選取:已暫停", + "kiroSelection.quotaExhausted": "不會自動選取:配額已用盡", + "kiroSelection.cooldown": "不會自動選取:冷卻中", + "kiroSelection.generic": "不會自動選取", "tray.updated": "更新於 {time} · 每60秒", "tray.today": "今天", "tray.input": "輸入", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 791c6bdd116..33b48c1a11d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,33 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "kiroLogin.title": "登录 Kiro", + "kiroLogin.chooseMethod": "选择登录方式", + "kiroLogin.cli": "从 Kiro CLI 导入", + "kiroLogin.builderId": "使用 Builder ID 登录", + "kiroLogin.google": "使用 Google 登录", + "kiroLogin.github": "使用 GitHub 登录", + "kiroLogin.addDescription": "通过设备登录添加 Kiro 账户,或导入 Kiro CLI 会话。", + "kiroLogin.loginDescription": "通过设备登录,或导入 Kiro CLI 会话。", + "kiroLogin.starting": "正在启动设备登录…", + "kiroLogin.pending": "在验证页面输入此代码,然后在此等待。", + "kiroLogin.done": "已添加 Kiro 账户。", + "kiroLogin.expired": "设备登录已过期。请重试。", + "kiroLogin.ended": "登录已结束。请检查账户列表。", + "kiroLogin.failed": "Kiro 登录失败。", + "kiroLogin.startFailed": "无法启动 Kiro 登录。请重试。", + "kiroLogin.tryAgain": "重试", + "kiroLogin.copyCode": "复制代码", + "kiroLogin.copied": "代码已复制", + "kiroLogin.copyUnavailable": "无法复制", + "kiroLogin.openVerification": "打开验证页面", + "kiroLogin.unexpectedHost": "验证主机异常。复制前请检查地址。", + "kiroLogin.duplicate": "此 Kiro 资料已在账户列表中。", + "kiroLogin.manualReview": "此账户可能需要人工检查。", + "kiroSelection.suspended": "不会自动选择:已暂停", + "kiroSelection.quotaExhausted": "不会自动选择:配额已用尽", + "kiroSelection.cooldown": "不会自动选择:冷却中", + "kiroSelection.generic": "不会自动选择", "tray.updated": "更新于 {time} · 每60秒", "tray.today": "今天", "tray.input": "输入", diff --git a/gui/src/kiro-device-login-finalizer.ts b/gui/src/kiro-device-login-finalizer.ts new file mode 100644 index 00000000000..a2327ea397f --- /dev/null +++ b/gui/src/kiro-device-login-finalizer.ts @@ -0,0 +1,83 @@ +import { parseKiroDeviceView, type KiroDeviceView } from "./kiro-device-login-helpers"; + +export type KiroFinalOutcome = "added" | "ended" | "failed"; +type Listener = (outcome: KiroFinalOutcome) => void; +const active = new Map>(); +const terminal = new Map(); +const listeners = new Map>(); +const keyFor = (apiBase: string, flowId: string) => JSON.stringify([apiBase, flowId]); +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +async function boundedRead(read: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([read, new Promise(resolve => { timer = setTimeout(() => resolve(null), ms); })]); + } finally { if (timer) clearTimeout(timer); } +} + +export function subscribeKiroDeviceFinal(apiBase: string, listener: Listener): () => void { + const set = listeners.get(apiBase) ?? new Set(); + set.add(listener); + listeners.set(apiBase, set); + return () => { set.delete(listener); if (set.size === 0) listeners.delete(apiBase); }; +} + +function finish(apiBase: string, flowId: string, outcome: KiroFinalOutcome): KiroFinalOutcome { + const key = keyFor(apiBase, flowId); + if (terminal.has(key)) return terminal.get(key)!; + terminal.set(key, outcome); + for (const listener of listeners.get(apiBase) ?? []) listener(outcome); + return outcome; +} + +/** A terminal status reply always outranks a later forgotten-flow 404. */ +export function observeKiroDeviceFinal(apiBase: string, flowId: string, view: KiroDeviceView, + source: "status" | "cancel" = "status"): KiroFinalOutcome | null { + if (view.flowId !== flowId) return null; + // Cancel may synthesize done before the credential write commits. A cancelled + // reply can also race an already-sent status read that observed the commit. + // Keep reconciling both until status confirms a terminal state. + if (source === "cancel" && view.state !== "failed") return null; + if (view.state === "done") return finish(apiBase, flowId, "added"); + if (view.state === "failed") return finish(apiBase, flowId, "failed"); + if (view.state === "expired" || view.state === "cancelled") return finish(apiBase, flowId, "ended"); + return null; +} + +/** Detached reconciliation survives dialog and page unmount. One loop per flowId. */ +export function finalizeKiroDeviceFlow(apiBase: string, flowId: string, expiresAt?: number, + inFlight?: Promise): Promise { + const key = keyFor(apiBase, flowId); + const prior = active.get(key); + if (prior) return prior; + const known = terminal.get(key); + if (known) return Promise.resolve(known); + const deadline = Math.min(Date.now() + 16 * 60_000, (expiresAt ?? Date.now() + 15 * 60_000) + 60_000); + const run = (async () => { + let pending = inFlight; + while (Date.now() < deadline) { + let response: Response | null; + try { + const remaining = deadline - Date.now(); + const timeout = Math.min(45_000, remaining); + response = await boundedRead(pending ?? fetch( + `${apiBase}/api/oauth/status?provider=kiro&flowId=${encodeURIComponent(flowId)}`, + { signal: AbortSignal.timeout(timeout) }, + ), timeout); + } catch { response = null; } + pending = undefined; + if (terminal.has(key)) return terminal.get(key)!; + if (response?.status === 404) return finish(apiBase, flowId, "ended"); + if (response?.ok) { + const view = parseKiroDeviceView(await response.json().catch(() => null)); + if (view) { + const result = observeKiroDeviceFinal(apiBase, flowId, view); + if (result) return result; + } + } + await delay(2_000); + } + return finish(apiBase, flowId, "ended"); + })().finally(() => { active.delete(key); }); + active.set(key, run); + return run; +} diff --git a/gui/src/kiro-device-login-helpers.ts b/gui/src/kiro-device-login-helpers.ts new file mode 100644 index 00000000000..b2785756ed2 --- /dev/null +++ b/gui/src/kiro-device-login-helpers.ts @@ -0,0 +1,63 @@ +import type { TKey } from "./i18n/shared"; + +export type KiroDeviceMethod = "builder-id" | "google" | "github"; +export type KiroDeviceState = "pending" | "done" | "failed" | "expired" | "cancelled"; +export interface KiroDeviceView { + flowId: string; + method: KiroDeviceMethod; + state: KiroDeviceState; + userCode?: string; + verificationUri?: string; + verificationUriComplete?: string; + expiresAt?: number; + warning?: "duplicate_profile_arn" | "manual_review_required"; +} + +const methods = new Set(["builder-id", "google", "github"]); +const states = new Set(["pending", "done", "failed", "expired", "cancelled"]); +/** Admit only the public device-view fields; an unexpected server field is never retained. */ +export function parseKiroDeviceView(input: unknown): KiroDeviceView | null { + if (!input || typeof input !== "object") return null; + const value = input as Record; + if (typeof value.flowId !== "string" || !value.flowId || !methods.has(value.method as KiroDeviceMethod) + || !states.has(value.state as KiroDeviceState)) return null; + return { + flowId: value.flowId, + method: value.method as KiroDeviceMethod, + state: value.state as KiroDeviceState, + ...(typeof value.userCode === "string" ? { userCode: value.userCode } : {}), + ...(typeof value.verificationUri === "string" ? { verificationUri: value.verificationUri } : {}), + ...(typeof value.verificationUriComplete === "string" ? { verificationUriComplete: value.verificationUriComplete } : {}), + ...(typeof value.expiresAt === "number" && Number.isFinite(value.expiresAt) ? { expiresAt: value.expiresAt } : {}), + ...(value.warning === "duplicate_profile_arn" || value.warning === "manual_review_required" ? { warning: value.warning } : {}), + }; +} + +/** URL parser normalization alone is insufficient: reject controls and explicit ports first. */ +export function kiroVerificationLink(raw: string | undefined): string | null { + if (!raw || [...raw].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) return null; + const authority = /^https:\/\/([^/?#]+)/iu.exec(raw)?.[1]; + if (!authority || authority.includes("@") || authority.includes(":")) return null; + try { + const url = new URL(raw); + if (url.protocol !== "https:" || url.username || url.password || url.port) return null; + const host = url.hostname.toLowerCase(); + if (host !== "device.sso.us-east-1.amazonaws.com" && host !== "kiro.dev" && !host.endsWith(".kiro.dev")) return null; + return url.href; + } catch { return null; } +} + +export function kiroSkipReasonKey(account: { + autoSelectable?: boolean; + skipReason?: string; + needsReauth?: boolean; + health?: { status: string }; +}, provider: string): TKey | null { + if (provider !== "kiro" || account.autoSelectable !== false) return null; + if (account.skipReason === "needs_reauth") return null; + if (account.skipReason === "cooldown" && account.health?.status === "cooldown") return null; + if (account.skipReason === "suspended") return "kiroSelection.suspended"; + if (account.skipReason === "quota_exhausted") return "kiroSelection.quotaExhausted"; + if (account.skipReason === "cooldown") return "kiroSelection.cooldown"; + return "kiroSelection.generic"; +} diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index c3b0ab7254f..cddbcf7df7e 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -25,6 +25,7 @@ import { useProviderModelsNotice } from "./use-provider-models-notice"; import { navigateHash } from "../hash-routing"; import { JEV_AUTO_CREATE_HASH } from "../app-routing"; import { useProviderSettingsDeepLink } from "./providers-deep-link"; +import { subscribeKiroDeviceFinal } from "../kiro-device-login-finalizer"; /** The page's real refresh tickets: only the captured report epoch and account read can settle them. */ // oxlint-disable-next-line react/only-export-components -- keep the page-owned coordinator and its direct race tests in the authorized owner. @@ -470,12 +471,20 @@ export default function Providers({ apiBase }: { apiBase: string }) { const bumpModelsRefresh = () => setModelsRefreshToken(n => n + 1); - const { cancelLoginOAuth, loginOAuth, logoutOAuth } = useProvidersOAuth({ + const { cancelLoginOAuth, loginOAuth, logoutOAuth, onNativeLoginSettled } = useProvidersOAuth({ apiBase, t, aliveRef, accountSets, setAccountSets, setBusy, setStatus, setLoginInfo, setOauthStatus, notify, fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, onLoginSettled: onProviderLoginSettled, }); + const nativeSettledRef = useRef(onNativeLoginSettled); + useEffect(() => { nativeSettledRef.current = onNativeLoginSettled; }, [onNativeLoginSettled]); + useEffect(() => subscribeKiroDeviceFinal(apiBase, outcome => { + void nativeSettledRef.current("kiro", outcome).catch(() => { + // The handler owns the outcome notice and catches expected roster errors; + // an unexpected callback failure must not become an unhandled rejection. + }); + }), [apiBase]); const { removeProvider, confirmRemoveProvider, setProviderDisabled, setDefaultProvider, updateProvider } = useProvidersCrud({ apiBase, t, removeBusyRef, workspaceSelected, setWorkspaceSelected, setRemoveConfirmName, @@ -643,6 +652,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { loginHint={loginInfo} authHandlers={{ onLogin: requestLoginOAuth, + onNativeLoginSettled, onCancelLogin: cancelLoginOAuth, onLogout: logoutOAuth, onReauth: (provider, accountId) => requestLoginOAuth(provider, true, accountId), diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index 4a68c8b9c1a..3df82bfed93 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -57,6 +57,32 @@ export function useProvidersOAuth({ const cancelServerLogin = useCallback((provider: string) => cancelOAuthLogin(apiBase, provider), [apiBase]); + const reloadAccountsAfterLogin = useCallback(async (provider: string) => { + const knownProviders = Object.keys(accountSets); + const knownSet = new Set(knownProviders); + await fetchAccountSets(knownSet.has(provider) ? knownProviders : [...knownProviders, provider]); + }, [accountSets, fetchAccountSets]); + + const refreshDerivedAfterLogin = useCallback(() => { + void fetchConfig(); + void fetchProviderQuotas(true); + bumpModelsRefresh(); + }, [fetchConfig, fetchProviderQuotas, bumpModelsRefresh]); + + const onNativeLoginSettled = useCallback(async (provider: string, outcome: "added" | "ended" | "failed") => { + if (!aliveRef.current) return; + if (outcome === "added") onLoginSettled?.(provider); + try { await reloadAccountsAfterLogin(provider); } catch { + // A failed roster read must not hide a confirmed device outcome. The + // account loader owns its own error state; derived reads can still heal it. + } + if (!aliveRef.current) return; + if (outcome === "added") notify(t("prov.loginOk", { provider: oauthLabel(provider), cmd: "ocx sync" }), true); + else if (outcome === "ended") notify(t("kiroLogin.ended"), false); + else notify(t("prov.loginError", { provider: oauthLabel(provider), error: t("kiroLogin.failed") }), false); + refreshDerivedAfterLogin(); + }, [aliveRef, onLoginSettled, reloadAccountsAfterLogin, notify, t, refreshDerivedAfterLogin]); + useEffect(() => { const cancelActiveLogins = (clearUi: boolean) => { const active = [...activeLoginGenerationsRef.current]; @@ -183,9 +209,7 @@ export function useProvidersOAuth({ } setLoginInfo(null); onLoginSettled?.(provider); - const knownProviders = Object.keys(accountSets); - const knownSet = new Set(knownProviders); - await fetchAccountSets(knownSet.has(provider) ? knownProviders : [...knownProviders, provider]); + await reloadAccountsAfterLogin(provider); if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return; const sameIdentityAdd = addAccount && !reauthTargetId && statusCount <= baselineCount; if (sameIdentityAdd) { @@ -193,9 +217,7 @@ export function useProvidersOAuth({ } else { notify(t("prov.loginOk", { provider: oauthLabel(provider), cmd: "ocx sync" }), true); } - void fetchConfig(); - void fetchProviderQuotas(true); - bumpModelsRefresh(); + refreshDerivedAfterLogin(); finished = true; break; } @@ -247,5 +269,5 @@ export function useProvidersOAuth({ } }; - return { cancelLoginOAuth, loginOAuth, logoutOAuth }; + return { cancelLoginOAuth, loginOAuth, logoutOAuth, onNativeLoginSettled }; } diff --git a/gui/src/styles/kiro-device-login.css b/gui/src/styles/kiro-device-login.css new file mode 100644 index 00000000000..63b3d2a1d20 --- /dev/null +++ b/gui/src/styles/kiro-device-login.css @@ -0,0 +1,8 @@ +.kiro-device-dialog { max-width: var(--prose-measure); width: 100%; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); color: var(--text); padding: var(--space-6); } +.kiro-device-dialog::backdrop { background: var(--glass-rail); } +.kiro-login-choices { display: grid; gap: var(--space-2); margin-block: var(--space-4); } +.kiro-login-choices .btn { justify-content: flex-start; min-height: var(--control-touch); } +.kiro-device-code { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); margin-block: var(--space-4); } +.kiro-device-code-value { font-family: var(--font-code); font-size: var(--text-title); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-wide); } +.kiro-device-destination { overflow-wrap: anywhere; user-select: all; } +.kiro-device-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-4); } diff --git a/gui/tests/kiro-account-skip-reason.test.tsx b/gui/tests/kiro-account-skip-reason.test.tsx new file mode 100644 index 00000000000..4addd00a848 --- /dev/null +++ b/gui/tests/kiro-account-skip-reason.test.tsx @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { kiroSkipReasonKey, kiroVerificationLink, parseKiroDeviceView } from "../src/kiro-device-login-helpers"; + +test("Kiro exclusion labels appear only for eligible rows and avoid duplicate health badges", () => { + expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "suspended" }, "kiro")).toBe("kiroSelection.suspended"); + expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "quota_exhausted" }, "kiro")).toBe("kiroSelection.quotaExhausted"); + expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "needs_reauth" }, "kiro")).toBeNull(); + expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "cooldown", health: { status: "cooldown" } }, "kiro")).toBeNull(); + expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "cooldown" }, "kiro")).toBe("kiroSelection.cooldown"); + expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "future_reason" }, "kiro")).toBe("kiroSelection.generic"); + expect(kiroSkipReasonKey({ autoSelectable: true, skipReason: "suspended" }, "kiro")).toBeNull(); + expect(kiroSkipReasonKey({ autoSelectable: false, skipReason: "suspended" }, "claude")).toBeNull(); +}); + +test("verification links accept only exact Builder ID and Kiro-owned https hosts", () => { + expect(kiroVerificationLink("https://device.sso.us-east-1.amazonaws.com/start")).toBe("https://device.sso.us-east-1.amazonaws.com/start"); + expect(kiroVerificationLink("https://kiro.dev/verify")).toBe("https://kiro.dev/verify"); + expect(kiroVerificationLink("https://auth.kiro.dev/verify")).toBe("https://auth.kiro.dev/verify"); + for (const url of [ + "https://evil.s3.amazonaws.com/", "https://x.awsapps.com/", "https://kiro.dev.evil.com/", + "https://evilkiro.dev/", ["https://user", "kiro.dev/"].join("@"), "https://kiro.dev:444/", + "https://kiro.dev:443/", "http://kiro.dev/", "https://kiro.dev/\nattack", "javascript:alert(1)", + ]) expect(kiroVerificationLink(url)).toBeNull(); +}); + +test("device parser ignores unknown fields and keeps only public view", () => { + expect(parseKiroDeviceView({ flowId: "f", method: "github", state: "pending", token: "secret", warning: "alien" })) + .toEqual({ flowId: "f", method: "github", state: "pending" }); +}); diff --git a/gui/tests/kiro-device-login.test.tsx b/gui/tests/kiro-device-login.test.tsx new file mode 100644 index 00000000000..8be8c8326e3 --- /dev/null +++ b/gui/tests/kiro-device-login.test.tsx @@ -0,0 +1,417 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createRef, useEffect, useRef } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import KiroDeviceLoginDialog from "../src/components/KiroDeviceLoginDialog"; +import { useKiroDeviceLogin } from "../src/components/use-kiro-device-login"; +import ProviderAuthPanel from "../src/components/provider-workspace/ProviderAuthPanel"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; +import type { ProviderAuthHandlers } from "../src/components/provider-workspace/types"; +import { en } from "../src/i18n/en"; +import { interpolate, type TFn } from "../src/i18n/shared"; +import { useProvidersOAuth } from "../src/pages/use-providers-oauth"; +import { finalizeKiroDeviceFlow } from "../src/kiro-device-login-finalizer"; +import { subscribeKiroDeviceFinal } from "../src/kiro-device-login-finalizer"; + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null; +let requests: { url: string; init?: RequestInit }[]; +let responder: (url: string, init?: RequestInit) => Promise; +let ticks: (() => void)[]; +let settled: string[]; +let closed: number; +let flowCounter = 0; +let flowId = ""; +const view = (state: string, extra: Record = {}) => ({ flowId, method: "builder-id", state, expiresAt: Date.now() + 60_000, userCode: "ABCD-1234", verificationUri: "https://kiro.dev/verify", ...extra }); +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +const deferred = () => { let resolve!: (value: T) => void; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; }; +const pollDelay = () => new Promise(resolve => { ticks.push(resolve); }); +const flush = async () => { await act(async () => { await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); }); }; + +beforeEach(() => { + previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, localStorage: { configurable: true, value: win.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + Object.defineProperty(win.HTMLDialogElement.prototype, "showModal", { configurable: true, value() { this.setAttribute("open", ""); } }); + Object.defineProperty(win.HTMLDialogElement.prototype, "close", { configurable: true, value() { this.removeAttribute("open"); } }); + requests = []; ticks = []; settled = []; closed = 0; root = null; flowId = `flow-${++flowCounter}`; + responder = async (url, init) => { + if (url.endsWith("/api/oauth/login") && init?.method === "POST") return json(view("pending")); + if (url.includes("/api/oauth/status?")) return json(view("done")); + if (url.endsWith("/api/oauth/login/cancel")) return json(view("cancelled")); + return json({}); + }; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (url: string, init?: RequestInit) => { + requests.push({ url, init }); return responder(url, init); + } }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); root = null; }); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + await win.happyDOM.close(); +}); + +async function mountDialog(delay: (ms: number, signal: AbortSignal) => Promise = pollDelay) { + const { createRoot } = await import("react-dom/client"); + const triggerRef = createRef(); + await act(async () => { + root = createRoot(host); + root.render( {}} + onClose={() => { closed++; }} onSettled={(_, outcome) => { settled.push(outcome); }} pollDelay={delay} />); + }); +} +async function click(label: string) { + const button = [...host.querySelectorAll("button")].find(item => item.textContent?.trim() === label); + expect(button).toBeTruthy(); + await act(async () => { button!.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); }); + await flush(); +} +async function start() { await click("Builder ID"); expect(requests.find(r => r.url.endsWith("/api/oauth/login"))?.init?.body).toBe(JSON.stringify({ provider: "kiro", method: "builder-id" })); } +async function tick() { expect(ticks.length).toBeGreaterThan(0); await act(async () => { ticks.shift()!(); }); await flush(); } +function expectCancelBeforeStatus() { + const cancelIndex = requests.findIndex(r => r.url.endsWith("/api/oauth/login/cancel") + && JSON.parse(String(r.init?.body)).flowId === flowId); + const statusIndex = requests.findIndex(r => r.url.includes(`/api/oauth/status?provider=kiro&flowId=${flowId}`)); + expect(cancelIndex).toBeGreaterThanOrEqual(0); + expect(statusIndex).toBeGreaterThan(cancelIndex); +} + +test("chooser starts native login, shows code and safe link, then confirms done", async () => { + await mountDialog(); await start(); + expect(host.textContent).toContain("ABCD-1234"); + expect(host.querySelector('a[href="https://kiro.dev/verify"]')?.getAttribute("target")).toBe("_blank"); + await tick(); + expect(settled).toEqual(["added"]); + expect(host.textContent).toContain("Kiro account added"); +}); + +test("unknown verification host is copyable text, not a link; warning is visible", async () => { + responder = async url => url.endsWith("/api/oauth/login") ? json(view("pending", { verificationUri: "https://x.awsapps.com/", warning: "duplicate_profile_arn" })) : json(view("cancelled")); + await mountDialog(); await start(); + expect(host.querySelector('a[href*="awsapps"]')).toBeNull(); + expect(host.textContent).toContain("https://x.awsapps.com/"); + expect(host.textContent).toContain("already in the account list"); +}); + +test("lost terminal reply becomes neutral and refreshes roster", async () => { + responder = async url => url.includes("/api/oauth/status?") ? json({ error: "unknown login flow" }, 404) : json(view("pending")); + await mountDialog(); await start(); await tick(); + expect(settled).toEqual(["ended"]); + expect(host.textContent).toContain("Check the account list"); +}); + +test("cancel posts flowId and provisional done is finalized by status", async () => { + responder = async url => url.endsWith("/api/oauth/login/cancel") ? json(view("done")) : url.includes("/api/oauth/status?") ? json(view("done")) : json(view("pending")); + const received: string[] = []; + const unsubscribe = subscribeKiroDeviceFinal("", outcome => received.push(outcome)); + await mountDialog(); await start(); await click("Cancel"); + expect(JSON.parse(String(requests.find(r => r.url.endsWith("/api/oauth/login/cancel"))?.init?.body))).toEqual({ provider: "kiro", flowId }); + await flush(); + expect(received).toEqual(["added"]); + unsubscribe(); +}); + +test("provisional cancel done followed by failed status reports failure", async () => { + responder = async url => url.endsWith("/api/oauth/login/cancel") ? json(view("done")) : url.includes("/api/oauth/status?") ? json(view("failed")) : json(view("pending")); + const received: string[] = []; + const unsubscribe = subscribeKiroDeviceFinal("", outcome => received.push(outcome)); + await mountDialog(); await start(); await click("Cancel"); await flush(); + expect(received).toEqual(["failed"]); + unsubscribe(); +}); + +test("close during unresolved start cancels returned flow", async () => { + const pending = deferred(); + responder = async url => url.endsWith("/api/oauth/login") ? pending.promise : json(view("cancelled")); + await mountDialog(); await click("Builder ID"); await click("Cancel"); + await act(async () => { pending.resolve(json(view("pending"))); }); await flush(); + expect(requests.some(r => r.url.endsWith("/api/oauth/login/cancel"))).toBe(true); +}); + +test("rapid starts create one request; Escape cancels", async () => { + const pending = deferred(); + responder = async url => url.endsWith("/api/oauth/login") ? pending.promise : json(view("cancelled")); + await mountDialog(); await click("Builder ID"); + const dialog = host.querySelector("dialog")!; + await act(async () => { dialog.dispatchEvent(new win.Event("cancel", { cancelable: true, bubbles: true })); }); + await act(async () => { pending.resolve(json(view("pending"))); }); await flush(); + expect(closed).toBe(1); + expect(requests.filter(r => r.url.endsWith("/api/oauth/login"))).toHaveLength(1); +}); + +test("panel Kiro Login offers CLI method, while other providers keep old login", async () => { + const { createRoot } = await import("react-dom/client"); + const calls: string[] = []; + const handlers: ProviderAuthHandlers = { + onLogin: (provider, add) => { calls.push(`${provider}:${Boolean(add)}`); }, onLogout: () => {}, onReauth: () => {}, + onSwitchAccount: () => {}, onRemoveAccount: () => {}, onAddApiKey: async () => true, + onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + }; + const item = (name: string): WorkspaceItem => ({ name, adapter: name, baseUrl: "https://example.com", authMode: "oauth" }); + await act(async () => { root = createRoot(host); root.render(); }); + await click("Login"); expect(host.textContent).toContain("Builder ID"); await click("Kiro CLI"); + expect(calls).toEqual(["kiro:false"]); + await act(async () => { root!.render(); }); + await click("Login"); expect(calls).toEqual(["kiro:false", "claude:false"]); +}); + +test("unmount during an unresolved start cancels the returned flow", async () => { + const pending = deferred(); + responder = async url => url.endsWith("/api/oauth/login") ? pending.promise : json(view("cancelled")); + await mountDialog(); await click("Builder ID"); + await act(async () => { root!.unmount(); root = null; }); + await act(async () => { pending.resolve(json(view("pending"))); }); await flush(); + expect(requests.some(r => r.url.endsWith("/api/oauth/login/cancel") && String(r.init?.body).includes(flowId))).toBe(true); +}); + +test("a pending status reply arriving after cancel cannot reopen the dialog", async () => { + const pending = deferred(); + responder = async url => url.includes("/api/oauth/status?") ? pending.promise : url.endsWith("/api/oauth/login/cancel") ? json(view("cancelled")) : json(view("pending")); + await mountDialog(); await start(); + await act(async () => { ticks.shift()!(); }); + await click("Cancel"); + await act(async () => { pending.resolve(json(view("pending"))); }); await flush(); + expect(host.textContent).not.toContain("Enter this code on the verification page"); + expect(settled).toEqual([]); +}); + +test("closing chooser returns focus to the trigger", async () => { + const { createRoot } = await import("react-dom/client"); + const handlers: ProviderAuthHandlers = { + onLogin: () => {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + }; + await act(async () => { root = createRoot(host); root.render(); }); + await click("Login"); await click("Cancel"); + expect((win.document.activeElement as HTMLElement).textContent?.trim()).toBe("Login"); +}); + +test("finalizer failed subscriber reloads accounts and shows failure without success reveal", async () => { + const { createRoot } = await import("react-dom/client"); + const notices: { text: string; ok: boolean }[] = []; + let reloads = 0; + let reveals = 0; + let handler: ((provider: string, outcome: "added" | "ended" | "failed") => Promise) | undefined; + const t: TFn = (key, vars) => interpolate(en[key], vars); + function Harness() { + const aliveRef = useRef(true); + const oauth = useProvidersOAuth({ + apiBase: "", t, aliveRef, accountSets: {}, setAccountSets: () => {}, setBusy: () => {}, setStatus: () => {}, + setLoginInfo: () => {}, setOauthStatus: () => {}, notify: (text, ok) => { notices.push({ text, ok }); }, + fetchConfig: async () => {}, fetchOauth: async () => {}, fetchAccountSets: async () => { reloads++; }, + fetchProviderQuotas: async () => {}, bumpModelsRefresh: () => {}, onLoginSettled: () => { reveals++; }, + }); + useEffect(() => { handler = oauth.onNativeLoginSettled; }); + return null; + } + await act(async () => { root = createRoot(host); root.render(); }); + const unsubscribe = subscribeKiroDeviceFinal("", outcome => { void handler?.("kiro", outcome); }); + responder = async url => url.includes("/api/oauth/status?") ? json(view("failed")) : json({}); + await act(async () => { await finalizeKiroDeviceFlow("", flowId, Date.now() + 10_000); }); + await flush(); + expect(reloads).toBe(1); + expect(reveals).toBe(0); + expect(notices).toHaveLength(1); + expect(notices[0]?.ok).toBe(false); + expect(notices[0]?.text).toContain("login error"); + unsubscribe(); +}); + +test("Kiro row chips render only exclusions not already shown by health", async () => { + const { createRoot } = await import("react-dom/client"); + const handlers: ProviderAuthHandlers = { + onLogin: () => {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + }; + const accounts = [ + { id: "suspended", active: true, autoSelectable: false, skipReason: "suspended" as const }, + { id: "quota", active: false, autoSelectable: false, skipReason: "quota_exhausted" as const }, + { id: "reauth", active: false, autoSelectable: false, skipReason: "needs_reauth" as const, needsReauth: true }, + { id: "cooldown", active: false, autoSelectable: false, skipReason: "cooldown" as const, health: { status: "cooldown" as const } }, + ]; + await act(async () => { root = createRoot(host); root.render(); }); + expect(host.textContent).toContain("Not auto-selected: suspended"); + expect(host.textContent).toContain("Not auto-selected: quota exhausted"); + expect(host.textContent).not.toContain("Not auto-selected: cooling down"); + expect(host.querySelectorAll(".badge-amber").length).toBeGreaterThan(0); +}); + +test("start errors stay visible without exposing server error data", async () => { + responder = async url => url.endsWith("/api/oauth/login") ? json({ error: "internal detail" }, 409) : json(view("cancelled")); + await mountDialog(); await click("Builder ID"); + expect(host.textContent).toContain("Could not start Kiro sign-in"); + expect(host.textContent).not.toContain("internal detail"); +}); + +test("manual review warning is shown without retaining unknown response fields", async () => { + responder = async url => url.endsWith("/api/oauth/login") ? json(view("pending", { warning: "manual_review_required", token: "private" })) : json(view("cancelled")); + await mountDialog(); await start(); + expect(host.textContent).toContain("Manual review may be needed"); + expect(host.textContent).not.toContain("private"); +}); + +test("cancel 404 produces neutral ended outcome", async () => { + responder = async url => url.endsWith("/api/oauth/login/cancel") || url.includes("/api/oauth/status?") + ? json({ error: "unknown login flow" }, 404) : json(view("pending")); + const received: string[] = []; + const unsubscribe = subscribeKiroDeviceFinal("", outcome => received.push(outcome)); + await mountDialog(); await start(); await click("Cancel"); await flush(); + expect(received).toEqual(["ended"]); + unsubscribe(); +}); + +test("a terminal status already in flight wins after cancel", async () => { + const pending = deferred(); + responder = async url => url.includes("/api/oauth/status?") ? pending.promise + : url.endsWith("/api/oauth/login/cancel") ? json(view("done")) : json(view("pending")); + const received: string[] = []; + const unsubscribe = subscribeKiroDeviceFinal("", outcome => received.push(outcome)); + await mountDialog(); await start(); + await act(async () => { ticks.shift()!(); }); + await click("Cancel"); + await act(async () => { pending.resolve(json(view("done"))); }); await flush(); + expect(received).toEqual(["added"]); + unsubscribe(); +}); + +test("two rapid native starts dispatch only one POST", async () => { + const { createRoot } = await import("react-dom/client"); + const pending = deferred(); + responder = async url => url.endsWith("/api/oauth/login") ? pending.promise : json(view("cancelled")); + let startLogin: ((method: "builder-id" | "google" | "github") => Promise) | undefined; + function Harness() { + const login = useKiroDeviceLogin("", undefined, pollDelay); + useEffect(() => { startLogin = login.start; }); + return null; + } + await act(async () => { root = createRoot(host); root.render(); }); + await act(async () => { void startLogin?.("builder-id"); void startLogin?.("github"); }); + expect(requests.filter(r => r.url.endsWith("/api/oauth/login"))).toHaveLength(1); + await act(async () => { root!.unmount(); root = null; pending.resolve(json(view("pending"))); }); + await flush(); +}); + +test("a hung cancel cannot block detached status reconciliation", async () => { + const never = deferred(); + responder = async url => url.endsWith("/api/oauth/login/cancel") ? never.promise + : url.includes("/api/oauth/status?") ? json(view("done")) : json(view("pending")); + const received: string[] = []; + const unsubscribe = subscribeKiroDeviceFinal("", outcome => received.push(outcome)); + await mountDialog(); await start(); await click("Cancel"); await flush(); + expectCancelBeforeStatus(); + expect(received).toEqual(["added"]); + unsubscribe(); +}); + +test("close during start dispatches cancel before detached status", async () => { + const pendingStart = deferred(); + const neverCancel = deferred(); + responder = async url => url.endsWith("/api/oauth/login") ? pendingStart.promise + : url.endsWith("/api/oauth/login/cancel") ? neverCancel.promise + : url.includes("/api/oauth/status?") ? json(view("done")) : json({}); + const received: string[] = []; + const unsubscribe = subscribeKiroDeviceFinal("", outcome => received.push(outcome)); + await mountDialog(); await click("Builder ID"); await click("Cancel"); + await act(async () => { pendingStart.resolve(json(view("pending"))); }); await flush(); + expectCancelBeforeStatus(); + expect(received).toEqual(["added"]); + unsubscribe(); +}); + +test("close during status body parsing leaves finalizer as sole outcome owner", async () => { + const body = deferred(); + let readingBody = false; + responder = async url => { + if (url.includes("/api/oauth/status?")) { + const response = json(view("done")); + Object.defineProperty(response, "json", { value: () => { readingBody = true; return body.promise; } }); + return response; + } + return url.endsWith("/api/oauth/login/cancel") ? json(view("cancelled")) : json(view("pending")); + }; + const received: string[] = []; + const unsubscribe = subscribeKiroDeviceFinal("", outcome => received.push(outcome)); + await mountDialog(); await start(); + await act(async () => { ticks.shift()!(); }); await flush(); + expect(readingBody).toBe(true); + await act(async () => { root!.unmount(); root = null; }); + await flush(); + expect(received).toEqual(["added"]); + await act(async () => { body.resolve(view("done")); }); await flush(); + expect(settled).toEqual([]); + expect(received).toEqual(["added"]); + unsubscribe(); +}); + +test("closing aborts the pending timer and never dispatches a hook status fetch", async () => { + let waitSignal: AbortSignal | undefined; + const cancellableDelay = (_ms: number, signal: AbortSignal) => new Promise(resolve => { + waitSignal = signal; + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + responder = async url => url.includes("/api/oauth/status?") ? json(view("done")) + : url.endsWith("/api/oauth/login/cancel") ? json(view("cancelled")) : json(view("pending")); + await mountDialog(cancellableDelay); await start(); + expect(waitSignal?.aborted).toBe(false); + await click("Cancel"); await flush(); + expect(waitSignal?.aborted).toBe(true); + // The one status GET belongs to the detached finalizer. The cancelled hook + // delay cannot send a second GET after close. + expect(requests.filter(r => r.url.includes("/api/oauth/status?"))).toHaveLength(1); +}); + +test("native outcomes remain visible when roster reload rejects", async () => { + const { createRoot } = await import("react-dom/client"); + const notices: { text: string; ok: boolean }[] = []; + let reloads = 0; + let reveals = 0; + let configReads = 0; + let quotaReads = 0; + let modelRefreshes = 0; + let handler: ((provider: string, outcome: "added" | "ended" | "failed") => Promise) | undefined; + const t: TFn = (key, vars) => interpolate(en[key], vars); + function Harness() { + const aliveRef = useRef(true); + const oauth = useProvidersOAuth({ + apiBase: "", t, aliveRef, accountSets: {}, setAccountSets: () => {}, setBusy: () => {}, setStatus: () => {}, + setLoginInfo: () => {}, setOauthStatus: () => {}, notify: (text, ok) => { notices.push({ text, ok }); }, + fetchConfig: async () => { configReads++; }, fetchOauth: async () => {}, + fetchAccountSets: async () => { reloads++; throw new Error("roster unavailable"); }, + fetchProviderQuotas: async () => { quotaReads++; }, bumpModelsRefresh: () => { modelRefreshes++; }, + onLoginSettled: () => { reveals++; }, + }); + useEffect(() => { handler = oauth.onNativeLoginSettled; }); + return null; + } + await act(async () => { root = createRoot(host); root.render(); }); + await act(async () => { await handler?.("kiro", "added"); }); + await flush(); + expect(notices.at(-1)).toMatchObject({ ok: true }); + expect(notices.at(-1)?.text).toContain("Logged in to kiro"); + expect(reveals).toBe(1); + await act(async () => { await handler?.("kiro", "failed"); }); + await flush(); + expect(notices.at(-1)).toMatchObject({ ok: false }); + expect(notices.at(-1)?.text).toContain("login error"); + expect(reveals).toBe(1); + expect(reloads).toBe(2); + expect(configReads).toBe(2); + expect(quotaReads).toBe(2); + expect(modelRefreshes).toBe(2); +}); diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 1a402478f97..fc13b4b5969 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -61,10 +61,10 @@ unsupported; deployments that previously relied on such embedding must open it a Kiro management login starts the native device flow only when `POST /api/oauth/login` supplies `method: "builder-id"`, `"google"`, or `"github"`. A method-less request retains -the Kiro CLI flow used by the dashboard. Native status and cancellation require `flowId`; +the Kiro CLI flow used by the dashboard chooser. The KiroDeviceLoginDialog and useKiroDeviceLogin GUI modules own the native chooser and polling. The kiro-device-login-finalizer GUI module continues terminal status reads after dialog unmount; a provisional cancel result is never treated as confirmed success. Native status and cancellation require `flowId`; provider-keyed status and cancellation retain their previous behavior. Device-flow responses contain only the flow handle, method, public verification fields, state, expiry, and a -duplicate-profile warning when applicable. The dashboard has no native device dialog. +duplicate-profile warning when applicable. The dashboard renders a native device dialog and links only exact Builder ID or Kiro-owned verification hosts. Kiro account rows show automatic-selection exclusion reasons when present. OpenCodex uses three mutually exclusive reusable admission credential classes: