diff --git a/frontend/src/features/accounts/components/oauth-dialog.test.tsx b/frontend/src/features/accounts/components/oauth-dialog.test.tsx index 532298a6..bb374a04 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, @@ -162,4 +167,85 @@ 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"); + }); + + it("renders a disabled loading refresh state while generating a fresh browser link", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: "Refreshing..." })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Change method" })).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(); + }); + + it("clears the pasted callback input when browser refresh disables the form", async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + const callbackInput = screen.getByPlaceholderText( + "http://localhost:1455/auth/callback?code=...&state=...", + ); + await user.type(callbackInput, "http://localhost:1455/auth/callback?code=abc&state=expected"); + expect(callbackInput).toHaveValue( + "http://localhost:1455/auth/callback?code=abc&state=expected", + ); + + rerender( + , + ); + + expect(callbackInput).toHaveValue(""); + expect(callbackInput).toBeDisabled(); + 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 042f3636..3b676bdd 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 ? ( @@ -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" /> void handleSubmit()} > {submitting ? "Submitting..." : "Submit"} @@ -126,6 +135,7 @@ export function OauthDialog({ const [selectedMethod, setSelectedMethod] = useState<"browser" | "device">("browser"); const stage = getStage(state); const completedRef = useRef(false); + const browserRefreshInProgress = stage === "browser" && state.status === "starting"; useEffect(() => { if (stage === "success" && !completedRef.current) { @@ -149,6 +159,10 @@ export function OauthDialog({ void onStart(selectedMethod); }; + const handleRefreshBrowserLink = () => { + void onStart("browser"); + }; + const handleChangeMethod = () => { onReset(); }; @@ -172,7 +186,7 @@ export function OauthDialog({ type="button" onClick={() => setSelectedMethod("browser")} className={cn( - "w-full rounded-lg border p-3 text-left transition-colors", + "w-full cursor-pointer rounded-lg border p-3 text-left transition-colors", selectedMethod === "browser" ? "border-primary bg-primary/5" : "hover:bg-muted/50", @@ -187,7 +201,7 @@ export function OauthDialog({ type="button" onClick={() => setSelectedMethod("device")} className={cn( - "w-full rounded-lg border p-3 text-left transition-colors", + "w-full cursor-pointer rounded-lg border p-3 text-left transition-colors", selectedMethod === "device" ? "border-primary bg-primary/5" : "hover:bg-muted/50", @@ -204,16 +218,46 @@ export function OauthDialog({ {/* Browser stage */} {stage === "browser" ? ( - {state.authorizationUrl ? ( - + + Authorization URL + + {browserRefreshInProgress ? ( + <> + + Refreshing... + > + ) : ( + <> + + Refresh link + > + )} + + + {browserRefreshInProgress ? ( + + + Generating a fresh sign-in link... + + ) : state.authorizationUrl ? ( {state.authorizationUrl} - - ) : null} - + ) : null} + + Refresh the link if the current sign-in page has already been used. + + + Waiting for authorization to complete... @@ -281,10 +325,19 @@ export function OauthDialog({ {stage === "intro" ? ( <> - close(false)}> + close(false)} + > Cancel - + Start sign-in > @@ -292,11 +345,21 @@ export function OauthDialog({ {stage === "browser" ? ( <> - + Change method - {state.authorizationUrl ? ( - + {state.authorizationUrl && !browserRefreshInProgress ? ( + Open sign-in page @@ -308,11 +371,20 @@ export function OauthDialog({ {stage === "device" ? ( <> - + Change method {state.verificationUrl ? ( - + Open link @@ -323,17 +395,30 @@ export function OauthDialog({ ) : null} {stage === "success" ? ( - close(false)}> + close(false)} + > Done ) : null} {stage === "error" ? ( <> - + Try again - close(false)}> + close(false)} + > Close > 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`.
Authorization URL
{state.authorizationUrl}
+ Refresh the link if the current sign-in page has already been used. +