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 ? : }
- {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..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: '