From 7a15f7ea4b76f6393ffd5bebfcec797d4ed2e98b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 07:48:19 +0000 Subject: [PATCH 1/3] Add Microsoft sign-in and provider-aware unverified-email copy Cloud ships /api/auth/microsoft/start and now names the provider an unverified-email rejection came from in `authProvider`. The sign-in page renders a "Continue with Microsoft" button beside Google, carrying the same `next` and invite, and words `unverified_email` for that provider, falling back to the Google copy when no provider is given. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016YZ81buxirGgH4cNoSviwz --- web/app/login/login.module.css | 19 ++++-- web/app/login/microsoft-icon.tsx | 10 +++ web/app/login/page.tsx | 14 ++-- ...-sign-in-button.tsx => sign-in-button.tsx} | 19 ++++-- web/lib/login.ts | 67 ++++++++++++++----- web/lib/test/login.test.tsx | 34 ++++++++-- 6 files changed, 124 insertions(+), 39 deletions(-) create mode 100644 web/app/login/microsoft-icon.tsx rename web/app/login/{google-sign-in-button.tsx => sign-in-button.tsx} (73%) diff --git a/web/app/login/login.module.css b/web/app/login/login.module.css index b3327cb9..b19d44b8 100644 --- a/web/app/login/login.module.css +++ b/web/app/login/login.module.css @@ -140,13 +140,18 @@ background: rgba(240, 114, 127, 0.12); } -.google { +.providers { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 32px; +} +.provider { display: flex; align-items: center; justify-content: center; gap: 12px; height: 50px; - margin-top: 32px; border: 1px solid rgba(13, 26, 39, 0.14); border-radius: 12px 12px 12px 4px; background: #fff; @@ -162,21 +167,21 @@ 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: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 +280,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..f182ca21 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 { SignInButton } from './sign-in-button'; import { RelayField } from './relay-field'; import s from './login.module.css'; @@ -49,7 +49,8 @@ export default async function LoginPage({ searchParams }: LoginProps) { first(params.inviteToken) ?? first(params.invite) )?.trim(); - const error = authErrorMessage(first(params.authError)); + const error = authErrorMessage(first(params.authError), first(params.authProvider)); + const destination = { next: first(params.next), invite }; return (
@@ -76,9 +77,10 @@ export default async function LoginPage({ searchParams }: LoginProps) {

)} - +
+ + +

Works with all your agents

diff --git a/web/app/login/google-sign-in-button.tsx b/web/app/login/sign-in-button.tsx similarity index 73% rename from web/app/login/google-sign-in-button.tsx rename to web/app/login/sign-in-button.tsx index d414ac49..849ec0fa 100644 --- a/web/app/login/google-sign-in-button.tsx +++ b/web/app/login/sign-in-button.tsx @@ -1,11 +1,18 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ComponentType } from 'react'; import { LoaderCircle } from 'lucide-react'; +import { signInProviderLabel, 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'; +const ICONS: Record = { + google: GoogleIcon, + microsoft: MicrosoftIcon, +}; + /** * A plain link to the OAuth start route, so sign-in works before hydration. * Once hydrated it shows progress and swallows repeat clicks, which would @@ -15,8 +22,10 @@ import s from './login.module.css'; */ const HANDOFF_MS = 8000; -export function GoogleSignInButton({ href }: { href: string }) { +export function SignInButton({ provider, href }: { provider: SignInProvider; href: string }) { const [pending, setPending] = useState(false); + const label = signInProviderLabel(provider); + const Icon = ICONS[provider]; useEffect(() => { // Back-navigation restores this page from bfcache with pending still set. @@ -37,7 +46,7 @@ export function GoogleSignInButton({ href }: { href: string }) { return ( { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) { @@ -50,8 +59,8 @@ export function GoogleSignInButton({ href }: { href: string }) { setPending(true); }} > - {pending ? ); } diff --git a/web/lib/login.ts b/web/lib/login.ts index 892f55f5..7a8eccf0 100644 --- a/web/lib/login.ts +++ b/web/lib/login.ts @@ -1,32 +1,69 @@ import { teamsCloudUrl } from './teams-cloud'; -/** Messages for the `authError` codes Cloud's Google callback redirects with. */ +/** The sign-in providers Cloud offers; `authProvider` names the one an error is about. */ +export const SIGN_IN_PROVIDERS = ['google', 'microsoft'] as const; +export type SignInProvider = (typeof SIGN_IN_PROVIDERS)[number]; + +const PROVIDER_LABELS: Readonly> = { + google: 'Google', + microsoft: 'Microsoft', +}; + +export function signInProviderLabel(provider: SignInProvider): string { + return PROVIDER_LABELS[provider]; +} + +function isSignInProvider(value: string | undefined): value is SignInProvider { + return !!value && (SIGN_IN_PROVIDERS as readonly string[]).includes(value); +} + +/** Messages for the `authError` codes Cloud's OAuth 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.', }; -export function authErrorMessage(code: string | undefined): string | null { - return code && Object.hasOwn(AUTH_ERROR_MESSAGES, code) ? AUTH_ERROR_MESSAGES[code] : null; +/** + * Codes shared across providers; Cloud names the provider in `authProvider`. + * Without one, the message falls back to Google, the provider that reported + * these codes before `authProvider` existed. + */ +const PROVIDER_ERROR_MESSAGES: Readonly string>> = { + unverified_email: (provider) => + `${provider} did not provide a verified email address. Use a ${provider} account with a verified email and try again.`, +}; + +export function authErrorMessage(code: string | undefined, provider?: string): string | null { + if (!code) return null; + if (Object.hasOwn(PROVIDER_ERROR_MESSAGES, code)) { + const label = isSignInProvider(provider) ? PROVIDER_LABELS[provider] : PROVIDER_LABELS.google; + return PROVIDER_ERROR_MESSAGES[code](label); + } + return Object.hasOwn(AUTH_ERROR_MESSAGES, code) ? AUTH_ERROR_MESSAGES[code] : null; } /** - * Cloud's Google start route owns validation: it normalizes `next` to a local - * Cloud path and the invite token to its canonical form, so both pass through. + * Cloud's provider start routes own validation: each 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}` : ''}`); +} + +export function googleSignInHref(options: { next?: string; invite?: string }): string { + return signInHref('google', options); +} + +export function microsoftSignInHref(options: { next?: string; invite?: string }): string { + return signInHref('microsoft', options); } diff --git a/web/lib/test/login.test.tsx b/web/lib/test/login.test.tsx index ab7000c4..e14fd768 100644 --- a/web/lib/test/login.test.tsx +++ b/web/lib/test/login.test.tsx @@ -1,31 +1,50 @@ 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, googleSignInHref, microsoftSignInHref } 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('Continue with Google'); + expect(html).toContain('href="/cloud/api/auth/microsoft/start?next=%2Fdashboard%2Fworkflows"'); + expect(html).toContain('Continue with Microsoft'); }); - it('acknowledges an invite and forwards its token', async () => { + it('acknowledges an invite and forwards its token to both providers', 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('href="/cloud/api/auth/google/start?invite_token=abc123"'); + expect(html).toContain('href="/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: '