From 44d1283bf4f718eb0af21ca1e3b36d8a11ec641c Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Sat, 24 Oct 2026 12:02:25 -0700 Subject: [PATCH 1/2] feat(login): Continue with Microsoft The sign-in page offers Google and Microsoft through one ProviderSignInButton; each links to Cloud's /api/auth//start with the destination and invite token. Co-Authored-By: Claude Opus 5.5 --- web/app/login/login.module.css | 15 ++++++---- web/app/login/microsoft-icon.tsx | 10 +++++++ web/app/login/page.tsx | 14 ++++++---- ...button.tsx => provider-sign-in-button.tsx} | 22 ++++++++++++--- web/lib/login.ts | 28 +++++++++++-------- web/lib/test/login.test.tsx | 20 +++++++++---- 6 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 web/app/login/microsoft-icon.tsx rename web/app/login/{google-sign-in-button.tsx => provider-sign-in-button.tsx} (76%) diff --git a/web/app/login/login.module.css b/web/app/login/login.module.css index b3327cb9..04a6c1ae 100644 --- a/web/app/login/login.module.css +++ b/web/app/login/login.module.css @@ -140,7 +140,7 @@ background: rgba(240, 114, 127, 0.12); } -.google { +.provider { display: flex; align-items: center; justify-content: center; @@ -162,21 +162,24 @@ transform 160ms ease; } @media (hover: hover) and (pointer: fine) { - .google:hover { + .provider:hover { background: #eef4f9; box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 14%, transparent), 0 12px 28px -12px rgba(0, 0, 0, 0.45); } } -.google:active { +.provider + .provider { + margin-top: 12px; +} +.provider:active { transform: scale(0.99); } -.google:focus-visible { +.provider:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; } -.google[aria-busy] { +.provider[aria-busy] { cursor: progress; color: #4b5563; } @@ -275,7 +278,7 @@ } @media (prefers-reduced-motion: reduce) { .field, - .google { + .provider { transition: none; } .form { diff --git a/web/app/login/microsoft-icon.tsx b/web/app/login/microsoft-icon.tsx new file mode 100644 index 00000000..bbd116ba --- /dev/null +++ b/web/app/login/microsoft-icon.tsx @@ -0,0 +1,10 @@ +export function MicrosoftIcon() { + return ( + + ); +} diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index de81c6c7..78c1d322 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -8,8 +8,8 @@ import { } from '../../components/AgentToolLogos'; import { Cursor, Grok, MuseColor } from '../../components/brand-icons'; import { LogoIcon, LogoWordmark } from '../../components/SiteNav'; -import { authErrorMessage, googleSignInHref } from '../../lib/login'; -import { GoogleSignInButton } from './google-sign-in-button'; +import { authErrorMessage, signInHref } from '../../lib/login'; +import { ProviderSignInButton } from './provider-sign-in-button'; import { RelayField } from './relay-field'; import s from './login.module.css'; @@ -76,9 +76,13 @@ export default async function LoginPage({ searchParams }: LoginProps) {

)} - + {(['google', 'microsoft'] as const).map((provider) => ( + + ))}

Works with all your agents

diff --git a/web/app/login/google-sign-in-button.tsx b/web/app/login/provider-sign-in-button.tsx similarity index 76% rename from web/app/login/google-sign-in-button.tsx rename to web/app/login/provider-sign-in-button.tsx index d414ac49..969243cc 100644 --- a/web/app/login/google-sign-in-button.tsx +++ b/web/app/login/provider-sign-in-button.tsx @@ -2,7 +2,9 @@ import { useEffect, useState } from 'react'; import { LoaderCircle } from 'lucide-react'; +import type { SignInProvider } from '../../lib/login'; import { GoogleIcon } from './google-icon'; +import { MicrosoftIcon } from './microsoft-icon'; import { RELAY_RUSH_EVENT } from './relay-events'; import s from './login.module.css'; @@ -15,7 +17,19 @@ import s from './login.module.css'; */ const HANDOFF_MS = 8000; -export function GoogleSignInButton({ href }: { href: string }) { +const PROVIDERS = { + google: { label: 'Google', Icon: GoogleIcon }, + microsoft: { label: 'Microsoft', Icon: MicrosoftIcon }, +} as const; + +export function ProviderSignInButton({ + provider, + href, +}: { + provider: SignInProvider; + href: string; +}) { + const { label, Icon } = PROVIDERS[provider]; const [pending, setPending] = useState(false); useEffect(() => { @@ -37,7 +51,7 @@ export function GoogleSignInButton({ href }: { href: string }) { return ( { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) { @@ -50,8 +64,8 @@ export function GoogleSignInButton({ href }: { href: string }) { setPending(true); }} > - {pending ? ); } diff --git a/web/lib/login.ts b/web/lib/login.ts index 892f55f5..006ceb90 100644 --- a/web/lib/login.ts +++ b/web/lib/login.ts @@ -1,32 +1,38 @@ import { teamsCloudUrl } from './teams-cloud'; -/** Messages for the `authError` codes Cloud's Google callback redirects with. */ +/** Messages for the `authError` codes Cloud's provider callbacks redirect with. */ const AUTH_ERROR_MESSAGES: Readonly> = { state: 'Your sign-in session expired. Please try again.', google: 'We couldn’t complete Google sign-in. Please try again.', + microsoft: 'We couldn’t complete Microsoft sign-in. Please try again.', schema: 'Sign-in is temporarily unavailable. Please try again shortly.', unverified_email: - 'Google did not provide a verified email address. Use a Google account with a verified email and try again.', + 'Your account did not provide a verified email address. Use an account with a verified email and try again.', }; export function authErrorMessage(code: string | undefined): string | null { return code && Object.hasOwn(AUTH_ERROR_MESSAGES, code) ? AUTH_ERROR_MESSAGES[code] : null; } +export type SignInProvider = 'google' | 'microsoft'; + /** - * Cloud's Google start route owns validation: it normalizes `next` to a local + * Cloud's provider start route owns validation: it normalizes `next` to a local * Cloud path and the invite token to its canonical form, so both pass through. */ -export function googleSignInHref({ - next, - invite, -}: { - next?: string; - invite?: string; -}): string { +export function signInHref( + provider: SignInProvider, + { + next, + invite, + }: { + next?: string; + invite?: string; + }, +): string { const params = new URLSearchParams(); if (next) params.set('next', next); if (invite) params.set('invite_token', invite); const query = params.toString(); - return teamsCloudUrl(`/api/auth/google/start${query ? `?${query}` : ''}`); + return teamsCloudUrl(`/api/auth/${provider}/start${query ? `?${query}` : ''}`); } diff --git a/web/lib/test/login.test.tsx b/web/lib/test/login.test.tsx index ab7000c4..d4f54976 100644 --- a/web/lib/test/login.test.tsx +++ b/web/lib/test/login.test.tsx @@ -1,28 +1,35 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import LoginPage from '../../app/login/page'; -import { authErrorMessage, googleSignInHref } from '../login'; +import { authErrorMessage, signInHref } from '../login'; const render = async (params: Record) => renderToStaticMarkup(await LoginPage({ searchParams: Promise.resolve(params) })); describe('login page', () => { - it('signs in through Cloud’s Google route, carrying the destination', async () => { + it('signs in through Cloud’s Google and Microsoft routes, carrying the destination', async () => { const html = await render({ next: '/dashboard/workflows' }); expect(html).toContain('Sign in to Agent Relay'); expect(html).toContain('href="/cloud/api/auth/google/start?next=%2Fdashboard%2Fworkflows"'); + expect(html).toContain('href="/cloud/api/auth/microsoft/start?next=%2Fdashboard%2Fworkflows"'); + expect(html).toContain('Continue with Google'); + expect(html).toContain('Continue with Microsoft'); }); - it('acknowledges an invite and forwards its token', async () => { + it('acknowledges an invite and forwards its token to every provider', async () => { const html = await render({ invite: ' abc123 ' }); expect(html).toContain('You’ve been invited to a workspace'); - expect(html).toContain('invite_token=abc123'); + expect(html).toContain('/cloud/api/auth/google/start?invite_token=abc123'); + expect(html).toContain('/cloud/api/auth/microsoft/start?invite_token=abc123'); }); it('announces known OAuth errors and ignores unknown codes', async () => { expect(await render({ authError: 'state' })).toMatch( /role="alert"[^>]*>Your sign-in session expired/, ); + expect(await render({ authError: 'microsoft' })).toMatch( + /role="alert"[^>]*>We couldn’t complete Microsoft sign-in/, + ); expect(await render({ authError: '