feat(FN-3617): align mailbox modal css with design tokens

Updated MailboxModal CSS to use design tokens instead of hardcoded values, and adjusted the co-located test to match.

Fusion-Task-Id: FN-3617
This commit is contained in:
Fusion
2026-05-06 16:08:21 -07:00
committed by gsxdsm
parent 2140148fe2
commit cabd6be84b
9 changed files with 264 additions and 79 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix Claude dashboard OAuth on remote hosts by using the pasted authorization-code flow instead of callback URL rewriting, while preserving callback proxy behavior for providers that still require it.

View File

@@ -304,10 +304,10 @@ For Capacitor + PWA workflow, see [MOBILE.md](./MOBILE.md).
### Provider authentication ### Provider authentication
Fusion supports OAuth-based authentication for AI providers configured via **Settings → Authentication**. When the dashboard is accessed via a non-localhost host (remote node, LAN host/IP, or reverse proxy), provider login URLs are automatically rewritten to route OAuth callbacks through a bridge endpoint (`/api/auth/oauth-callback`), ensuring the redirect reaches the active browser session. Fusion supports OAuth-based authentication for AI providers configured via **Settings → Authentication**. For most OAuth providers, when the dashboard is accessed via a non-localhost host (remote node, LAN host/IP, or reverse proxy), provider login URLs are rewritten to route OAuth callbacks through a bridge endpoint (`/api/auth/oauth-callback`) so redirects reach the active browser session.
- **Anthropic (Claude)** — Authenticates via the same Settings/onboarding OAuth flow as other OAuth providers - **Anthropic (Claude)** — Uses a pasted authorization-code flow in Settings/onboarding: sign in, then paste the final redirect URL (or code) back into Fusion to complete login
- **OpenAI Codex** — Authenticates via Settings OAuth flow with secure state validation - **OpenAI Codex** — Uses the same pasted authorization-code flow with secure state validation
- **Factory AI — via Droid CLI** *(optional)* — requires local Droid CLI install + `droid auth login`; detection follows the effective runtime binary path (default `droid`, or plugin `droidBinaryPath` when configured), then enable in **Settings → Authentication** and restart Fusion - **Factory AI — via Droid CLI** *(optional)* — requires local Droid CLI install + `droid auth login`; detection follows the effective runtime binary path (default `droid`, or plugin `droidBinaryPath` when configured), then enable in **Settings → Authentication** and restart Fusion
- **llama.cpp — via HTTP server** *(optional)* — configure your llama.cpp server URL (default `http://127.0.0.1:8080`) and optional API key, then enable in **Settings → Authentication** - **llama.cpp — via HTTP server** *(optional)* — configure your llama.cpp server URL (default `http://127.0.0.1:8080`) and optional API key, then enable in **Settings → Authentication**
- **Other providers** — Authenticate via API key entry in Settings (including Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code aliases) - **Other providers** — Authenticate via API key entry in Settings (including Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code aliases)

View File

@@ -74,7 +74,7 @@ fn dashboard
On first launch, Fusion opens an onboarding wizard with three steps: On first launch, Fusion opens an onboarding wizard with three steps:
1. **AI Setup** — choose a provider and authenticate (you only need one to start). Anthropic/Claude and OpenAI Codex support direct OAuth login in onboarding and Settings, while **Anthropic — via Claude CLI** remains available as a separate optional path. Deprecated Google Gemini CLI / Antigravity entries are hidden; Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code options remain available. 1. **AI Setup** — choose a provider and authenticate (you only need one to start). Anthropic/Claude and OpenAI Codex use a pasted authorization-code OAuth flow in onboarding and Settings (sign in, then paste the final redirect URL or code back into Fusion), while **Anthropic — via Claude CLI** remains available as a separate optional path. Deprecated Google Gemini CLI / Antigravity entries are hidden; Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code options remain available.
2. **GitHub (Optional)** — connect GitHub for issue import and PR workflows 2. **GitHub (Optional)** — connect GitHub for issue import and PR workflows
3. **First Task** — create your first task or import one from GitHub 3. **First Task** — create your first task or import one from GitHub

View File

@@ -10,13 +10,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: var(--space-md);
} }
.mailbox-title { .mailbox-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--space-sm);
font-weight: 600; font-weight: 600;
font-size: 1rem; font-size: 1rem;
} }
@@ -38,7 +38,7 @@
.mailbox-header-actions { .mailbox-header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--space-sm);
} }
.mailbox-tabs { .mailbox-tabs {
@@ -77,7 +77,7 @@
min-width: 16px; min-width: 16px;
height: 16px; height: 16px;
padding: 0 4px; padding: 0 4px;
border-radius: 8px; border-radius: var(--radius-md);
background: var(--color-error); background: var(--color-error);
color: var(--fab-text); color: var(--fab-text);
font-size: 0.65rem; font-size: 0.65rem;
@@ -87,9 +87,9 @@
.mailbox-content { .mailbox-content {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
padding: 16px; padding: var(--space-lg);
overflow-y: auto; overflow-y: auto;
max-height: calc(80vh - 140px); max-height: calc(80vh - var(--header-height) - var(--space-2xl) - var(--space-xl));
position: relative; position: relative;
} }
@@ -104,10 +104,10 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 48px 16px; padding: calc(var(--space-2xl) + var(--space-lg)) var(--space-lg);
color: var(--text-muted); color: var(--text-muted);
text-align: center; text-align: center;
gap: 12px; gap: var(--space-md);
} }
.mailbox-empty p { .mailbox-empty p {
@@ -118,11 +118,11 @@
.mailbox-item { .mailbox-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: var(--space-md);
padding: 12px; padding: var(--space-md);
border-radius: 8px; border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
transition: background-color 0.1s; transition: background-color var(--transition-instant);
} }
.mailbox-item:hover { .mailbox-item:hover {
@@ -155,7 +155,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: var(--space-sm);
margin-bottom: 2px; margin-bottom: 2px;
} }
@@ -202,11 +202,11 @@
.mailbox-conversation-group { .mailbox-conversation-group {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: var(--space-md);
padding: 12px; padding: var(--space-md);
border-radius: 8px; border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
transition: background-color 0.1s; transition: background-color var(--transition-instant);
position: relative; position: relative;
} }
@@ -239,21 +239,21 @@
.mailbox-message-detail { .mailbox-message-detail {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: var(--space-lg);
} }
.mailbox-message-detail-header { .mailbox-message-detail-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: var(--space-md);
flex-wrap: wrap; flex-wrap: wrap;
} }
.mailbox-message-detail-meta { .mailbox-message-detail-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--space-sm);
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -261,7 +261,7 @@
.mailbox-message-detail-actions { .mailbox-message-detail-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--space-sm);
} }
.mailbox-message-type { .mailbox-message-type {
@@ -282,7 +282,7 @@
gap: 24px; gap: 24px;
padding: 12px; padding: 12px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-radius: 8px; border-radius: var(--radius-md);
} }
.mailbox-participant { .mailbox-participant {
@@ -304,9 +304,9 @@
} }
.mailbox-message-body { .mailbox-message-body {
padding: 16px; padding: var(--space-lg);
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 8px; border-radius: var(--radius-md);
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1.5; line-height: 1.5;
white-space: pre-wrap; white-space: pre-wrap;
@@ -329,7 +329,7 @@
.mailbox-conversation { .mailbox-conversation {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: var(--space-sm);
} }
.mailbox-conversation-label { .mailbox-conversation-label {
@@ -340,7 +340,7 @@
.mailbox-conversation-msg { .mailbox-conversation-msg {
padding: 8px 12px; padding: 8px 12px;
border-radius: 8px; border-radius: var(--radius-md);
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-left: 3px solid transparent; border-left: 3px solid transparent;
} }
@@ -354,7 +354,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: var(--space-sm);
margin-bottom: 4px; margin-bottom: 4px;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: var(--text-muted);
@@ -371,7 +371,7 @@
.mailbox-agents { .mailbox-agents {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: var(--space-md);
min-height: 300px; min-height: 300px;
} }
@@ -382,7 +382,7 @@
.mailbox-agents-header { .mailbox-agents-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: var(--space-md);
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -441,13 +441,13 @@
.mailbox-skeleton { .mailbox-skeleton {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: var(--space-sm);
} }
.mailbox-skeleton-item { .mailbox-skeleton-item {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: var(--space-md);
padding: 12px; padding: 12px;
} }
@@ -557,7 +557,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.mailbox-modal .mailbox-header { .mailbox-modal .mailbox-header {
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: var(--space-sm);
} }
.mailbox-modal .mailbox-title { .mailbox-modal .mailbox-title {
@@ -605,13 +605,14 @@
.mailbox-modal .mailbox-tab { .mailbox-modal .mailbox-tab {
flex-shrink: 0; flex-shrink: 0;
padding: 8px 12px; padding: var(--space-sm) var(--space-md);
font-size: 0.8rem; font-size: 0.8rem;
} }
.mailbox-modal .mailbox-content { .mailbox-modal .mailbox-content {
max-height: calc(100dvh - 120px); max-height: calc(100dvh - var(--header-height) - var(--space-2xl) - var(--space-xl));
padding: 12px; padding: var(--space-md);
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
} }
.mailbox-modal .mailbox-message-detail-header { .mailbox-modal .mailbox-message-detail-header {
@@ -625,11 +626,11 @@
.mailbox-modal .mailbox-message-participants { .mailbox-modal .mailbox-message-participants {
flex-direction: column; flex-direction: column;
gap: 8px; gap: var(--space-sm);
} }
.mailbox-modal .mailbox-conversation-msg { .mailbox-modal .mailbox-conversation-msg {
padding: 6px 10px; padding: var(--space-xs) var(--space-md);
} }
.mailbox-modal .mailbox-agent-select { .mailbox-modal .mailbox-agent-select {
@@ -641,7 +642,7 @@
} }
.mailbox-modal .mailbox-empty { .mailbox-modal .mailbox-empty {
padding: 32px 12px; padding: var(--space-2xl) var(--space-md);
} }
.mailbox-modal .mailbox-agent-subtabs { .mailbox-modal .mailbox-agent-subtabs {
@@ -652,13 +653,13 @@
flex: 1; flex: 1;
justify-content: center; justify-content: center;
min-height: 36px; min-height: 36px;
padding: 8px 12px; padding: var(--space-sm) var(--space-md);
} }
/* Mailbox View (full-page) mobile overrides */ /* Mailbox View (full-page) mobile overrides */
.mailbox-view .mailbox-header { .mailbox-view .mailbox-header {
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: var(--space-sm);
} }
.mailbox-view .mailbox-title { .mailbox-view .mailbox-title {
@@ -677,7 +678,7 @@
.mailbox-view .mailbox-tab { .mailbox-view .mailbox-tab {
flex-shrink: 0; flex-shrink: 0;
padding: 8px 12px; padding: var(--space-sm) var(--space-md);
font-size: 0.8rem; font-size: 0.8rem;
} }
@@ -686,7 +687,7 @@
overflow-y: auto; overflow-y: auto;
padding: var(--space-md); padding: var(--space-md);
/* Account for mobile nav bar at bottom */ /* Account for mobile nav bar at bottom */
padding-bottom: calc(var(--mobile-nav-height) + var(--standalone-bottom-gap) + var(--space-lg)); padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap) + var(--space-lg));
} }
.mailbox-view .mailbox-split-layout { .mailbox-view .mailbox-split-layout {
@@ -714,11 +715,11 @@
.mailbox-view .mailbox-message-participants { .mailbox-view .mailbox-message-participants {
flex-direction: column; flex-direction: column;
gap: 8px; gap: var(--space-sm);
} }
.mailbox-view .mailbox-conversation-msg { .mailbox-view .mailbox-conversation-msg {
padding: 6px 10px; padding: var(--space-xs) var(--space-md);
} }
.mailbox-view .mailbox-agent-select { .mailbox-view .mailbox-agent-select {
@@ -730,7 +731,7 @@
} }
.mailbox-view .mailbox-empty { .mailbox-view .mailbox-empty {
padding: 32px 12px; padding: var(--space-2xl) var(--space-md);
} }
.mailbox-view .mailbox-agent-subtabs { .mailbox-view .mailbox-agent-subtabs {
@@ -741,7 +742,7 @@
flex: 1; flex: 1;
justify-content: center; justify-content: center;
min-height: 36px; min-height: 36px;
padding: 8px 12px; padding: var(--space-sm) var(--space-md);
} }
/* Message Composer mobile overrides */ /* Message Composer mobile overrides */
@@ -788,13 +789,13 @@
.message-composer-body { .message-composer-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: var(--space-md);
} }
.message-composer-field { .message-composer-field {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 8px; gap: var(--space-sm);
} }
.message-composer-field--content { .message-composer-field--content {
@@ -905,10 +906,10 @@
.message-composer-error { .message-composer-error {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--space-sm);
padding: 8px 12px; padding: var(--space-sm) var(--space-md);
background: color-mix(in srgb, var(--color-error) 10%, transparent); background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-radius: 6px; border-radius: var(--radius-sm);
color: var(--color-error); color: var(--color-error);
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -917,6 +918,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: var(--space-sm);
} }

View File

@@ -933,23 +933,23 @@ describe("MailboxModal", () => {
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-tabs::-webkit-scrollbar"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-tabs::-webkit-scrollbar");
expect(mailboxMobileSection).toContain("display: none;"); expect(mailboxMobileSection).toContain("display: none;");
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-tab"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-tab");
expect(mailboxMobileSection).toContain("padding: 8px 12px;"); expect(mailboxMobileSection).toContain("padding: var(--space-sm) var(--space-md);");
expect(mailboxMobileSection).toContain("font-size: 0.8rem;"); expect(mailboxMobileSection).toContain("font-size: 0.8rem;");
expect(mailboxMobileSection).toContain("max-height: calc(100dvh - 120px);"); expect(mailboxMobileSection).toContain("max-height: calc(100dvh - var(--header-height) - var(--space-2xl) - var(--space-xl));");
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-detail-header"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-detail-header");
expect(mailboxMobileSection).toContain("flex-direction: column;"); expect(mailboxMobileSection).toContain("flex-direction: column;");
expect(mailboxMobileSection).toContain("align-items: flex-start;"); expect(mailboxMobileSection).toContain("align-items: flex-start;");
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-detail-actions"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-detail-actions");
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-participants"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-participants");
expect(mailboxMobileSection).toContain("gap: 8px;"); expect(mailboxMobileSection).toContain("gap: var(--space-sm);");
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-conversation-msg"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-conversation-msg");
expect(mailboxMobileSection).toContain("padding: 6px 10px;"); expect(mailboxMobileSection).toContain("padding: var(--space-xs) var(--space-md);");
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agent-select"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agent-select");
expect(mailboxMobileSection).toContain("max-width: 100%;"); expect(mailboxMobileSection).toContain("max-width: 100%;");
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agents"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agents");
expect(mailboxMobileSection).toContain("min-height: 200px;"); expect(mailboxMobileSection).toContain("min-height: 200px;");
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-empty"); expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-empty");
expect(mailboxMobileSection).toContain("padding: 32px 12px;"); expect(mailboxMobileSection).toContain("padding: var(--space-2xl) var(--space-md);");
}); });
it("renders detail-view structural hooks targeted by mobile overrides", async () => { it("renders detail-view structural hooks targeted by mobile overrides", async () => {

View File

@@ -10,6 +10,7 @@ const mockFetchAuthStatus = vi.fn();
const mockLoginProvider = vi.fn(); const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn(); const mockLogoutProvider = vi.fn();
const mockCancelProviderLogin = vi.fn(); const mockCancelProviderLogin = vi.fn();
const mockSubmitProviderManualCode = vi.fn();
const mockSaveApiKey = vi.fn(); const mockSaveApiKey = vi.fn();
const mockClearApiKey = vi.fn(); const mockClearApiKey = vi.fn();
const mockFetchModels = vi.fn(); const mockFetchModels = vi.fn();
@@ -24,6 +25,7 @@ vi.mock("../../api", () => ({
loginProvider: (...args: unknown[]) => mockLoginProvider(...args), loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args), logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args), cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args), saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
clearApiKey: (...args: unknown[]) => mockClearApiKey(...args), clearApiKey: (...args: unknown[]) => mockClearApiKey(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args), fetchModels: (...args: unknown[]) => mockFetchModels(...args),
@@ -183,6 +185,7 @@ beforeEach(() => {
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" }); mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
mockLogoutProvider.mockResolvedValue({ success: true }); mockLogoutProvider.mockResolvedValue({ success: true });
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true }); mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
mockSubmitProviderManualCode.mockResolvedValue({ success: true, submitted: true });
mockSaveApiKey.mockResolvedValue({ success: true }); mockSaveApiKey.mockResolvedValue({ success: true });
mockClearApiKey.mockResolvedValue({ success: true }); mockClearApiKey.mockResolvedValue({ success: true });
// Default to no persisted state (start at ai-setup) // Default to no persisted state (start at ai-setup)
@@ -652,6 +655,68 @@ describe("ModelOnboardingModal", () => {
}); });
}); });
it("shows Anthropic pasted-code form and submits manual code", async () => {
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
mockLoginProvider.mockResolvedValueOnce({
url: "https://claude.ai/oauth/authorize",
manualCode: {
prompt: "Paste the final redirect URL or authorization code",
placeholder: "http://localhost:*/callback?code=...&state=... or just the code",
helpText: "After Claude sign-in, copy the full browser URL (or just the code) and paste it here to finish login from this dashboard host.",
},
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
const prompt = await screen.findByText("Paste the final redirect URL or authorization code");
const card = prompt.closest(".onboarding-provider-card") as HTMLElement;
const textbox = within(card).getByRole("textbox");
fireEvent.change(textbox, { target: { value: "anthropic-code" } });
fireEvent.click(within(card).getByRole("button", { name: "Submit code" }));
await waitFor(() => {
expect(mockSubmitProviderManualCode).toHaveBeenCalledWith("anthropic", "anthropic-code");
});
expect(mockWindowOpen).toHaveBeenCalled();
});
it("keeps OpenAI Codex manual-code UX available in onboarding", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth" }],
});
mockLoginProvider.mockResolvedValueOnce({
url: "https://auth.openai.com/oauth/authorize",
manualCode: {
prompt: "Paste the final redirect URL or authorization code",
placeholder: "http://localhost:1455/auth/callback?code=...&state=... or just the code",
helpText: "After sign-in, OpenAI may redirect to a localhost callback that cannot open from this dashboard host. Copy the full browser URL from the address bar and paste it here.",
},
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Advanced provider settings/ })).toBeTruthy();
});
fireEvent.click(screen.getByRole("button", { name: /Advanced provider settings/ }));
await waitFor(() => {
expect(screen.getByText("OpenAI Codex")).toBeTruthy();
});
fireEvent.click(screen.getByText("Login"));
expect(await screen.findByText("Paste the final redirect URL or authorization code")).toBeTruthy();
expect(screen.getByText(/OpenAI may redirect to a localhost callback/)).toBeTruthy();
});
it("saves API key when Save is clicked", async () => { it("saves API key when Save is clicked", async () => {
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />); render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
@@ -1376,7 +1441,7 @@ describe("ModelOnboardingModal", () => {
const badge = screen.getByTestId("github-status-badge"); const badge = screen.getByTestId("github-status-badge");
expect(badge).toHaveTextContent("✗ Connection failed"); expect(badge).toHaveTextContent("✗ Connection failed");
expect(badge).toHaveClass("retry"); expect(badge).toHaveClass("retry");
}); }, { timeout: 3000 });
expect(screen.getByText("Connection failed or timed out.")).toBeTruthy(); expect(screen.getByText("Connection failed or timed out.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();

View File

@@ -16,6 +16,7 @@ const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn(); const mockLogoutProvider = vi.fn();
const mockCancelProviderLogin = vi.fn(); const mockCancelProviderLogin = vi.fn();
const mockSaveApiKey = vi.fn(); const mockSaveApiKey = vi.fn();
const mockSubmitProviderManualCode = vi.fn();
const mockFetchModels = vi.fn(); const mockFetchModels = vi.fn();
const mockFetchCustomProviders = vi.fn(); const mockFetchCustomProviders = vi.fn();
const mockCreateCustomProvider = vi.fn(); const mockCreateCustomProvider = vi.fn();
@@ -69,6 +70,7 @@ vi.mock("../../api", async (importOriginal) => {
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args), logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args), cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args), saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args), fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args), fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args), createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
@@ -266,6 +268,7 @@ describe("SettingsModal", () => {
mockDeleteCustomProvider.mockResolvedValue(undefined); mockDeleteCustomProvider.mockResolvedValue(undefined);
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true }); mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
mockSaveApiKey.mockResolvedValue(undefined); mockSaveApiKey.mockResolvedValue(undefined);
mockSubmitProviderManualCode.mockResolvedValue({ success: true, submitted: true });
mockTestNotification.mockResolvedValue({ success: true }); mockTestNotification.mockResolvedValue({ success: true });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 }); mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
mockFetchMemoryFiles.mockResolvedValue({ mockFetchMemoryFiles.mockResolvedValue({
@@ -1048,6 +1051,36 @@ describe("SettingsModal", () => {
expect(openSpy).toHaveBeenCalled(); expect(openSpy).toHaveBeenCalled();
}); });
it("renders Anthropic pasted-code form when login response includes manualCode", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }],
});
mockLoginProvider.mockResolvedValueOnce({
url: "https://claude.ai/oauth/authorize",
manualCode: {
prompt: "Paste the final redirect URL or authorization code",
placeholder: "http://localhost:*/callback?code=...&state=... or just the code",
helpText: "After Claude sign-in, copy the full browser URL (or just the code) and paste it here to finish login from this dashboard host.",
},
});
renderModal();
await waitForSettingsModalReady();
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
expect(await within(anthropicCard).findByText("Paste the final redirect URL or authorization code")).toBeInTheDocument();
await userEvent.type(within(anthropicCard).getByRole("textbox"), "anthropic-code");
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Submit code" }));
await waitFor(() => {
expect(mockSubmitProviderManualCode).toHaveBeenCalledWith("anthropic", "anthropic-code");
});
expect(openSpy).toHaveBeenCalled();
});
it("shows cancel action for server-reported pending oauth login", async () => { it("shows cancel action for server-reported pending oauth login", async () => {
mockFetchAuthStatus.mockResolvedValue({ mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: true }], providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: true }],

View File

@@ -1163,6 +1163,36 @@ describe("POST /auth/login", () => {
helpText: "After sign-in, OpenAI may redirect to a localhost callback that cannot open from this dashboard host. Copy the full browser URL from the address bar and paste it here.", helpText: "After sign-in, OpenAI may redirect to a localhost callback that cannot open from this dashboard host. Copy the full browser URL from the address bar and paste it here.",
}); });
}); });
it("returns manual-code flow for anthropic and skips callback rewrite on remote hosts", async () => {
const unchangedUrl =
"https://claude.ai/oauth/authorize?state=anthropic-state&redirect_uri=http%3A%2F%2Flocalhost%3A3210%2Fauth%2Fcallback";
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
callbacks.onAuth({ url: unchangedUrl, instructions: "Sign in with Claude" });
return Promise.resolve();
});
const res = await REQUEST(
buildApp(),
"POST",
"/api/auth/login",
JSON.stringify({ provider: "anthropic", origin: "https://my-host.example.com" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.url).toBe(unchangedUrl);
expect(res.body.instructions).toContain("After Claude sign-in");
expect(res.body.manualCode).toEqual({
prompt: "Paste the final redirect URL or authorization code",
placeholder: "http://localhost:*/callback?code=...&state=... or just the code",
helpText: "After Claude sign-in, copy the full browser URL (or just the code) and paste it here to finish login from this dashboard host.",
});
});
it("returns 400 when provider is missing", async () => { it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), { const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -1302,7 +1332,10 @@ describe("POST /auth/manual-code", () => {
beforeEach(() => { beforeEach(() => {
store = createMockStore(); store = createMockStore();
authStorage = createMockAuthStorage({ authStorage = createMockAuthStorage({
getOAuthProviders: vi.fn().mockReturnValue([{ id: "openai-codex", name: "OpenAI Codex" }]), getOAuthProviders: vi.fn().mockReturnValue([
{ id: "openai-codex", name: "OpenAI Codex" },
{ id: "anthropic", name: "Anthropic" },
]),
}); });
}); });
@@ -1358,6 +1391,46 @@ describe("POST /auth/manual-code", () => {
}); });
}); });
it("submits pasted manual code for anthropic login", async () => {
let submittedCode: string | undefined;
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(
async (_provider: string, callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt?: () => Promise<string>;
}) => {
callbacks.onAuth({
url: "https://claude.ai/oauth/authorize?state=anthropic-state&redirect_uri=http%3A%2F%2Flocalhost%3A3210%2Fauth%2Fcallback",
});
submittedCode = await callbacks.onPrompt?.();
},
);
const app = buildApp();
const loginRes = await REQUEST(
app,
"POST",
"/api/auth/login",
JSON.stringify({ provider: "anthropic", origin: "https://remote.example.com" }),
{ "Content-Type": "application/json" },
);
expect(loginRes.status).toBe(200);
const submitRes = await REQUEST(
app,
"POST",
"/api/auth/manual-code",
JSON.stringify({ provider: "anthropic", code: "anthropic-manual-code" }),
{ "Content-Type": "application/json" },
);
expect(submitRes.status).toBe(200);
expect(submitRes.body).toEqual({ success: true, submitted: true });
await vi.waitFor(() => {
expect(submittedCode).toBe("anthropic-manual-code");
});
});
it("returns 409 when no login is in progress", async () => { it("returns 409 when no login is in progress", async () => {
const res = await REQUEST( const res = await REQUEST(
buildApp(), buildApp(),

View File

@@ -138,12 +138,9 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
return false; return false;
} }
// The upstream OpenAI Codex OAuth provider is hardcoded to request and // These providers rely on pasted-code UX with their own localhost callbacks,
// later redeem the localhost callback URI `http://localhost:1455/auth/callback`. // so redirect_uri must remain untouched.
// Rewriting that authorize-time redirect_uri to the dashboard proxy causes if (providerId === "openai-codex" || providerId === "anthropic") {
// OpenAI auth to fail with an upstream unknown_error. Keep the original
// localhost callback for this provider.
if (providerId === "openai-codex") {
return false; return false;
} }
@@ -151,18 +148,29 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
} }
function getManualCodeConfig(providerId: string, origin: string | undefined): ManualCodeConfig | undefined { function getManualCodeConfig(providerId: string, origin: string | undefined): ManualCodeConfig | undefined {
if (providerId !== "openai-codex") { const remoteDashboard = origin !== undefined && !isLocalhostOrigin(origin);
return undefined;
if (providerId === "openai-codex") {
return {
prompt: "Paste the final redirect URL or authorization code",
placeholder: "http://localhost:1455/auth/callback?code=...&state=... or just the code",
helpText: remoteDashboard
? "After sign-in, OpenAI may redirect to a localhost callback that cannot open from this dashboard host. Copy the full browser URL from the address bar and paste it here."
: "If the browser cannot finish the localhost callback automatically, copy the full browser URL from the address bar and paste it here.",
};
} }
const remoteDashboard = origin !== undefined && !isLocalhostOrigin(origin); if (providerId === "anthropic") {
return { return {
prompt: "Paste the final redirect URL or authorization code", prompt: "Paste the final redirect URL or authorization code",
placeholder: "http://localhost:1455/auth/callback?code=...&state=... or just the code", placeholder: "http://localhost:*/callback?code=...&state=... or just the code",
helpText: remoteDashboard helpText: remoteDashboard
? "After sign-in, OpenAI may redirect to a localhost callback that cannot open from this dashboard host. Copy the full browser URL from the address bar and paste it here." ? "After Claude sign-in, copy the full browser URL (or just the code) and paste it here to finish login from this dashboard host."
: "If the browser cannot finish the localhost callback automatically, copy the full browser URL from the address bar and paste it here.", : "If Claude cannot finish the localhost callback automatically, copy the full browser URL from the address bar and paste it here.",
}; };
}
return undefined;
} }
async function probeDroidCliWithEffectiveBinary(req?: Request) { async function probeDroidCliWithEffectiveBinary(req?: Request) {