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:
@@ -10,13 +10,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.mailbox-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
.mailbox-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-tabs {
|
||||
@@ -77,7 +77,7 @@
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-error);
|
||||
color: var(--fab-text);
|
||||
font-size: 0.65rem;
|
||||
@@ -87,9 +87,9 @@
|
||||
.mailbox-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
padding: var(--space-lg);
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 140px);
|
||||
max-height: calc(80vh - var(--header-height) - var(--space-2xl) - var(--space-xl));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -104,10 +104,10 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
padding: calc(var(--space-2xl) + var(--space-lg)) var(--space-lg);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.mailbox-empty p {
|
||||
@@ -118,11 +118,11 @@
|
||||
.mailbox-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
transition: background-color var(--transition-instant);
|
||||
}
|
||||
|
||||
.mailbox-item:hover {
|
||||
@@ -155,7 +155,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
@@ -202,11 +202,11 @@
|
||||
.mailbox-conversation-group {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s;
|
||||
transition: background-color var(--transition-instant);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -239,21 +239,21 @@
|
||||
.mailbox-message-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.mailbox-message-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mailbox-message-detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -261,7 +261,7 @@
|
||||
.mailbox-message-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-message-type {
|
||||
@@ -282,7 +282,7 @@
|
||||
gap: 24px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.mailbox-participant {
|
||||
@@ -304,9 +304,9 @@
|
||||
}
|
||||
|
||||
.mailbox-message-body {
|
||||
padding: 16px;
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
@@ -329,7 +329,7 @@
|
||||
.mailbox-conversation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-conversation-label {
|
||||
@@ -340,7 +340,7 @@
|
||||
|
||||
.mailbox-conversation-msg {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-tertiary);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
@@ -354,7 +354,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
@@ -371,7 +371,7 @@
|
||||
.mailbox-agents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@
|
||||
.mailbox-agents-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -441,13 +441,13 @@
|
||||
.mailbox-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-skeleton-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -557,7 +557,7 @@
|
||||
@media (max-width: 768px) {
|
||||
.mailbox-modal .mailbox-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-title {
|
||||
@@ -605,13 +605,14 @@
|
||||
|
||||
.mailbox-modal .mailbox-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-content {
|
||||
max-height: calc(100dvh - 120px);
|
||||
padding: 12px;
|
||||
max-height: calc(100dvh - var(--header-height) - var(--space-2xl) - var(--space-xl));
|
||||
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 {
|
||||
@@ -625,11 +626,11 @@
|
||||
|
||||
.mailbox-modal .mailbox-message-participants {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-conversation-msg {
|
||||
padding: 6px 10px;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-agent-select {
|
||||
@@ -641,7 +642,7 @@
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-empty {
|
||||
padding: 32px 12px;
|
||||
padding: var(--space-2xl) var(--space-md);
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-agent-subtabs {
|
||||
@@ -652,13 +653,13 @@
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
/* Mailbox View (full-page) mobile overrides */
|
||||
.mailbox-view .mailbox-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-title {
|
||||
@@ -677,7 +678,7 @@
|
||||
|
||||
.mailbox-view .mailbox-tab {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@@ -686,7 +687,7 @@
|
||||
overflow-y: auto;
|
||||
padding: var(--space-md);
|
||||
/* 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 {
|
||||
@@ -714,11 +715,11 @@
|
||||
|
||||
.mailbox-view .mailbox-message-participants {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-conversation-msg {
|
||||
padding: 6px 10px;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-agent-select {
|
||||
@@ -730,7 +731,7 @@
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-empty {
|
||||
padding: 32px 12px;
|
||||
padding: var(--space-2xl) var(--space-md);
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-agent-subtabs {
|
||||
@@ -741,7 +742,7 @@
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 8px 12px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
/* Message Composer mobile overrides */
|
||||
@@ -788,13 +789,13 @@
|
||||
.message-composer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.message-composer-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.message-composer-field--content {
|
||||
@@ -905,10 +906,10 @@
|
||||
.message-composer-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-error);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -917,6 +918,6 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -933,23 +933,23 @@ describe("MailboxModal", () => {
|
||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-tabs::-webkit-scrollbar");
|
||||
expect(mailboxMobileSection).toContain("display: none;");
|
||||
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("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("flex-direction: column;");
|
||||
expect(mailboxMobileSection).toContain("align-items: flex-start;");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-message-detail-actions");
|
||||
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("padding: 6px 10px;");
|
||||
expect(mailboxMobileSection).toContain("padding: var(--space-xs) var(--space-md);");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agent-select");
|
||||
expect(mailboxMobileSection).toContain("max-width: 100%;");
|
||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agents");
|
||||
expect(mailboxMobileSection).toContain("min-height: 200px;");
|
||||
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 () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ const mockFetchAuthStatus = vi.fn();
|
||||
const mockLoginProvider = vi.fn();
|
||||
const mockLogoutProvider = vi.fn();
|
||||
const mockCancelProviderLogin = vi.fn();
|
||||
const mockSubmitProviderManualCode = vi.fn();
|
||||
const mockSaveApiKey = vi.fn();
|
||||
const mockClearApiKey = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
@@ -24,6 +25,7 @@ vi.mock("../../api", () => ({
|
||||
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
|
||||
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
|
||||
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
|
||||
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
|
||||
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
|
||||
clearApiKey: (...args: unknown[]) => mockClearApiKey(...args),
|
||||
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
||||
@@ -183,6 +185,7 @@ beforeEach(() => {
|
||||
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
|
||||
mockLogoutProvider.mockResolvedValue({ success: true });
|
||||
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
|
||||
mockSubmitProviderManualCode.mockResolvedValue({ success: true, submitted: true });
|
||||
mockSaveApiKey.mockResolvedValue({ success: true });
|
||||
mockClearApiKey.mockResolvedValue({ success: true });
|
||||
// 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 () => {
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
@@ -1376,7 +1441,7 @@ describe("ModelOnboardingModal", () => {
|
||||
const badge = screen.getByTestId("github-status-badge");
|
||||
expect(badge).toHaveTextContent("✗ Connection failed");
|
||||
expect(badge).toHaveClass("retry");
|
||||
});
|
||||
}, { timeout: 3000 });
|
||||
|
||||
expect(screen.getByText("Connection failed or timed out.")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
|
||||
|
||||
@@ -16,6 +16,7 @@ const mockLoginProvider = vi.fn();
|
||||
const mockLogoutProvider = vi.fn();
|
||||
const mockCancelProviderLogin = vi.fn();
|
||||
const mockSaveApiKey = vi.fn();
|
||||
const mockSubmitProviderManualCode = vi.fn();
|
||||
const mockFetchModels = vi.fn();
|
||||
const mockFetchCustomProviders = vi.fn();
|
||||
const mockCreateCustomProvider = vi.fn();
|
||||
@@ -69,6 +70,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
|
||||
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
|
||||
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
|
||||
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
|
||||
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
|
||||
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
|
||||
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
|
||||
@@ -266,6 +268,7 @@ describe("SettingsModal", () => {
|
||||
mockDeleteCustomProvider.mockResolvedValue(undefined);
|
||||
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
|
||||
mockSaveApiKey.mockResolvedValue(undefined);
|
||||
mockSubmitProviderManualCode.mockResolvedValue({ success: true, submitted: true });
|
||||
mockTestNotification.mockResolvedValue({ success: true });
|
||||
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
|
||||
mockFetchMemoryFiles.mockResolvedValue({
|
||||
@@ -1048,6 +1051,36 @@ describe("SettingsModal", () => {
|
||||
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 () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: true }],
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1302,7 +1332,10 @@ describe("POST /auth/manual-code", () => {
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
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 () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
|
||||
@@ -138,12 +138,9 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The upstream OpenAI Codex OAuth provider is hardcoded to request and
|
||||
// later redeem the localhost callback URI `http://localhost:1455/auth/callback`.
|
||||
// Rewriting that authorize-time redirect_uri to the dashboard proxy causes
|
||||
// OpenAI auth to fail with an upstream unknown_error. Keep the original
|
||||
// localhost callback for this provider.
|
||||
if (providerId === "openai-codex") {
|
||||
// These providers rely on pasted-code UX with their own localhost callbacks,
|
||||
// so redirect_uri must remain untouched.
|
||||
if (providerId === "openai-codex" || providerId === "anthropic") {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -151,18 +148,29 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
}
|
||||
|
||||
function getManualCodeConfig(providerId: string, origin: string | undefined): ManualCodeConfig | undefined {
|
||||
if (providerId !== "openai-codex") {
|
||||
return undefined;
|
||||
const remoteDashboard = origin !== undefined && !isLocalhostOrigin(origin);
|
||||
|
||||
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);
|
||||
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.",
|
||||
};
|
||||
if (providerId === "anthropic") {
|
||||
return {
|
||||
prompt: "Paste the final redirect URL or authorization code",
|
||||
placeholder: "http://localhost:*/callback?code=...&state=... or just the code",
|
||||
helpText: remoteDashboard
|
||||
? "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 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) {
|
||||
|
||||
Reference in New Issue
Block a user