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..0d158b31 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,11 @@ 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 };
+ // Microsoft is not rendered until Cloud's Microsoft sign-in is verified end
+ // to end; `?microsoft=true` reveals it.
+ const showMicrosoft = first(params.microsoft) === 'true';
return (
@@ -76,9 +80,12 @@ export default async function LoginPage({ searchParams }: LoginProps) {
)}
-
+
+
+ {showMicrosoft && (
+
+ )}
+
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 72%
rename from web/app/login/google-sign-in-button.tsx
rename to web/app/login/sign-in-button.tsx
index d414ac49..9d3e9ac1 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,16 @@ 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 +52,7 @@ export function GoogleSignInButton({ href }: { href: string }) {
return (
{
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) {
@@ -50,8 +65,8 @@ export function GoogleSignInButton({ href }: { href: string }) {
setPending(true);
}}
>
- {pending ? : }
- {pending ? 'Redirecting to Google…' : 'Continue with Google'}
+ {pending ? : }
+ {pending ? `Redirecting to ${label}…` : `Continue with ${label}`}
);
}
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..fef4618a 100644
--- a/web/lib/test/login.test.tsx
+++ b/web/lib/test/login.test.tsx
@@ -1,31 +1,67 @@
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 route without a Microsoft button by default', 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).not.toContain('Continue with Microsoft');
+ expect(html).not.toContain('/cloud/api/auth/microsoft/start');
});
- it('acknowledges an invite and forwards its token', async () => {
- const html = await render({ invite: ' abc123 ' });
+ it('renders the Microsoft button with ?microsoft=true, carrying the destination', async () => {
+ const html = await render({ microsoft: 'true', next: '/dashboard/workflows' });
+ 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('does not render the Microsoft button unless microsoft is exactly "true"', async () => {
+ for (const microsoft of ['false', '1', 'TRUE']) {
+ const html = await render({ microsoft });
+ expect(html, `microsoft=${microsoft}`).toContain('Continue with Google');
+ expect(html, `microsoft=${microsoft}`).not.toContain('Continue with Microsoft');
+ expect(html, `microsoft=${microsoft}`).not.toContain('/cloud/api/auth/microsoft/start');
+ }
+ });
+
+ it('acknowledges an invite and forwards its token to both providers', async () => {
+ const html = await render({ invite: ' abc123 ', microsoft: 'true' });
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: '