From a94db1102518f8746e0bd29d3fbeece059820181 Mon Sep 17 00:00:00 2001 From: Hugh Do Date: Fri, 3 Apr 2026 14:15:34 +0700 Subject: [PATCH 1/4] Add refreshable browser OAuth link --- .../accounts/components/oauth-dialog.test.tsx | 21 ++++ .../accounts/components/oauth-dialog.tsx | 99 ++++++++++++++++--- .../refresh-browser-oauth-link/proposal.md | 12 +++ .../specs/frontend-architecture/spec.md | 40 ++++++++ .../refresh-browser-oauth-link/tasks.md | 11 +++ 5 files changed, 168 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/refresh-browser-oauth-link/proposal.md create mode 100644 openspec/changes/refresh-browser-oauth-link/specs/frontend-architecture/spec.md create mode 100644 openspec/changes/refresh-browser-oauth-link/tasks.md diff --git a/frontend/src/features/accounts/components/oauth-dialog.test.tsx b/frontend/src/features/accounts/components/oauth-dialog.test.tsx index 532298a6..d063c24d 100644 --- a/frontend/src/features/accounts/components/oauth-dialog.test.tsx +++ b/frontend/src/features/accounts/components/oauth-dialog.test.tsx @@ -162,4 +162,25 @@ describe("OauthDialog", () => { "http://localhost:1455/auth/callback?code=abc&state=expected", ); }); + + it("refreshes the browser authorization link without leaving the dialog", async () => { + const user = userEvent.setup(); + const onStart = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Refresh link" })); + + expect(onStart).toHaveBeenCalledWith("browser"); + }); }); diff --git a/frontend/src/features/accounts/components/oauth-dialog.tsx b/frontend/src/features/accounts/components/oauth-dialog.tsx index 042f3636..78cf7378 100644 --- a/frontend/src/features/accounts/components/oauth-dialog.tsx +++ b/frontend/src/features/accounts/components/oauth-dialog.tsx @@ -1,4 +1,4 @@ -import { Check, CircleAlert, Copy, ExternalLink, Loader2 } from "lucide-react"; +import { Check, CircleAlert, Copy, ExternalLink, Loader2, RefreshCw } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -38,7 +38,7 @@ function CopyButton({ text }: { text: string }) { type="button" size="sm" variant="ghost" - className="h-7 gap-1 px-2 text-xs" + className="h-7 cursor-pointer gap-1 px-2 text-xs disabled:cursor-not-allowed" onClick={() => void handleCopy()} > {copied ? ( @@ -93,7 +93,7 @@ function ManualCallbackInput({ +

{state.authorizationUrl}

+

+ Refresh the link if the current sign-in page has already been used. +

) : null} @@ -281,10 +310,19 @@ export function OauthDialog({ {stage === "intro" ? ( <> - - @@ -292,11 +330,20 @@ export function OauthDialog({ {stage === "browser" ? ( <> - {state.authorizationUrl ? ( - {state.verificationUrl ? ( - ) : null} {stage === "error" ? ( <> - - diff --git a/openspec/changes/refresh-browser-oauth-link/proposal.md b/openspec/changes/refresh-browser-oauth-link/proposal.md new file mode 100644 index 00000000..1280afbc --- /dev/null +++ b/openspec/changes/refresh-browser-oauth-link/proposal.md @@ -0,0 +1,12 @@ +## Why +The Accounts page OAuth dialog currently generates a browser PKCE authorization link only when the user first clicks `Start sign-in`. Because the link is single-use, operators who want to sign in multiple accounts in sequence must leave the current browser step and start over to mint another link. That adds unnecessary friction to a common dashboard workflow. + +## What Changes +- Add an explicit refresh action to the browser PKCE stage of the Accounts OAuth dialog. +- Reuse the existing browser OAuth start flow so refreshing creates a fresh authorization URL without forcing the user to leave the dialog. +- Cover the refreshed-link behavior with frontend tests. + +## Impact +- Affects the Accounts page OAuth dialog in the frontend. +- Reuses the existing `/api/oauth/start` browser flow and its PKCE/state regeneration. +- No API contract changes are required. diff --git a/openspec/changes/refresh-browser-oauth-link/specs/frontend-architecture/spec.md b/openspec/changes/refresh-browser-oauth-link/specs/frontend-architecture/spec.md new file mode 100644 index 00000000..7f038630 --- /dev/null +++ b/openspec/changes/refresh-browser-oauth-link/specs/frontend-architecture/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Accounts page + +The Accounts page SHALL display a two-column layout: left panel with searchable account list, import button, and add account button; right panel with selected account details including usage, token info, and actions (pause/resume/delete/re-authenticate). + +#### Scenario: Account selection + +- **WHEN** a user clicks an account in the list +- **THEN** the right panel shows the selected account's details + +#### Scenario: Account import + +- **WHEN** a user clicks the import button and uploads an auth.json file +- **THEN** the app calls `POST /api/accounts/import` and refreshes the account list on success + +#### Scenario: Ambiguous duplicate identity import conflict + +- **WHEN** `importWithoutOverwrite` was previously enabled and duplicate accounts with the same email exist +- **AND** overwrite mode is enabled again +- **AND** a new import matches multiple existing accounts by email without an exact ID match +- **THEN** `POST /api/accounts/import` returns `409` with `error.code=duplicate_identity_conflict` +- **AND** no existing account is modified + +#### Scenario: OAuth add account + +- **WHEN** a user clicks the add account button +- **THEN** an OAuth dialog opens with browser and device code flow options + +#### Scenario: Browser OAuth link refresh + +- **WHEN** a user is on the browser PKCE step of the OAuth dialog +- **AND** the current authorization URL has already been used or needs to be replaced +- **THEN** the dialog offers a refresh action that starts the browser OAuth flow again without leaving the dialog +- **AND** the dialog updates to the newly generated authorization URL + +#### Scenario: Account actions + +- **WHEN** a user clicks pause/resume/delete on an account +- **THEN** the corresponding API is called and the account list is refreshed diff --git a/openspec/changes/refresh-browser-oauth-link/tasks.md b/openspec/changes/refresh-browser-oauth-link/tasks.md new file mode 100644 index 00000000..f93138f5 --- /dev/null +++ b/openspec/changes/refresh-browser-oauth-link/tasks.md @@ -0,0 +1,11 @@ +## 1. Spec +- [x] 1.1 Add a frontend-architecture delta that defines refreshing the browser OAuth link from the Accounts dialog. + +## 2. Implementation +- [x] 2.1 Add a refresh action to the browser PKCE stage of the Accounts OAuth dialog. +- [x] 2.2 Reuse the existing browser OAuth start flow so refreshing mints a new authorization URL without leaving the dialog. + +## 3. Validation +- [x] 3.1 Add or update frontend tests covering the refresh action. +- [x] 3.2 Run the targeted frontend OAuth dialog tests. +- [x] 3.3 Validate specs locally with `openspec validate --specs`. From c1a039c1c3621d3ea1cbd17c90c01fb081421bda Mon Sep 17 00:00:00 2001 From: Hugh Do Date: Fri, 3 Apr 2026 14:23:24 +0700 Subject: [PATCH 2/4] Harden browser OAuth link refresh state --- .../accounts/components/oauth-dialog.test.tsx | 25 ++++++ .../accounts/components/oauth-dialog.tsx | 81 +++++++++++-------- 2 files changed, 73 insertions(+), 33 deletions(-) diff --git a/frontend/src/features/accounts/components/oauth-dialog.test.tsx b/frontend/src/features/accounts/components/oauth-dialog.test.tsx index d063c24d..3ee21b38 100644 --- a/frontend/src/features/accounts/components/oauth-dialog.test.tsx +++ b/frontend/src/features/accounts/components/oauth-dialog.test.tsx @@ -43,6 +43,11 @@ const browserPendingState = { errorMessage: null, }; +const browserStartingState = { + ...browserPendingState, + status: "starting" as const, +}; + const successState = { ...idleState, status: "success" as const, @@ -183,4 +188,24 @@ describe("OauthDialog", () => { expect(onStart).toHaveBeenCalledWith("browser"); }); + + it("renders a disabled loading refresh state while generating a fresh browser link", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Refreshing..." })).toBeDisabled(); + expect(screen.getByText("Generating a fresh sign-in link...")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Copy" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Open sign-in page" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + }); }); diff --git a/frontend/src/features/accounts/components/oauth-dialog.tsx b/frontend/src/features/accounts/components/oauth-dialog.tsx index 78cf7378..ec6bc13f 100644 --- a/frontend/src/features/accounts/components/oauth-dialog.tsx +++ b/frontend/src/features/accounts/components/oauth-dialog.tsx @@ -58,12 +58,20 @@ function CopyButton({ text }: { text: string }) { function ManualCallbackInput({ onSubmit, + disabled = false, }: { onSubmit: (callbackUrl: string) => Promise; + disabled?: boolean; }) { const [callbackUrl, setCallbackUrl] = useState(""); const [submitting, setSubmitting] = useState(false); + useEffect(() => { + if (disabled) { + setCallbackUrl(""); + } + }, [disabled]); + const handleSubmit = useCallback(async () => { if (!callbackUrl.trim()) return; setSubmitting(true); @@ -87,14 +95,15 @@ function ManualCallbackInput({ type="text" value={callbackUrl} onChange={(e) => setCallbackUrl(e.target.value)} + disabled={disabled} placeholder="http://localhost:1455/auth/callback?code=...&state=..." - className="flex-1 rounded-lg border bg-muted/20 px-3 py-2 font-mono text-xs outline-none focus:ring-1 focus:ring-primary" + className="flex-1 rounded-lg border bg-muted/20 px-3 py-2 font-mono text-xs outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60" /> +
+
+

Authorization URL

+ +
+ {browserRefreshInProgress ? ( +
+ + Generating a fresh sign-in link...
+ ) : state.authorizationUrl ? (

{state.authorizationUrl}

-

- Refresh the link if the current sign-in page has already been used. -

-
- ) : null} - + ) : null} +

+ Refresh the link if the current sign-in page has already been used. +

+ +
Waiting for authorization to complete... @@ -338,7 +353,7 @@ export function OauthDialog({ > Change method - {state.authorizationUrl ? ( + {state.authorizationUrl && !browserRefreshInProgress ? (