Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions web/app/login/login.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -275,7 +280,7 @@
}
@media (prefers-reduced-motion: reduce) {
.field,
.google {
.provider {
transition: none;
}
.form {
Expand Down
10 changes: 10 additions & 0 deletions web/app/login/microsoft-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function MicrosoftIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-5 w-5">
<path d="M2 2h9.5v9.5H2z" fill="#F25022" />
<path d="M12.5 2H22v9.5h-9.5z" fill="#7FBA00" />
<path d="M2 12.5h9.5V22H2z" fill="#00A4EF" />
<path d="M12.5 12.5H22V22h-9.5z" fill="#FFB900" />
</svg>
);
}
19 changes: 13 additions & 6 deletions web/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<div className={s.page}>
Expand All @@ -76,9 +80,12 @@ export default async function LoginPage({ searchParams }: LoginProps) {
</p>
)}

<GoogleSignInButton
href={googleSignInHref({ next: first(params.next), invite })}
/>
<div className={s.providers}>
<SignInButton provider="google" href={signInHref('google', destination)} />
{showMicrosoft && (
<SignInButton provider="microsoft" href={signInHref('microsoft', destination)} />
)}
</div>

<div className={s.worksWith}>
<p>Works with all your agents</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SignInProvider, ComponentType> = {
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
Expand All @@ -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.
Expand All @@ -37,7 +52,7 @@ export function GoogleSignInButton({ href }: { href: string }) {
return (
<a
href={href}
className={s.google}
className={s.provider}
aria-busy={pending || undefined}
onClick={(event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) {
Expand All @@ -50,8 +65,8 @@ export function GoogleSignInButton({ href }: { href: string }) {
setPending(true);
}}
>
{pending ? <LoaderCircle className={s.spin} aria-hidden="true" /> : <GoogleIcon />}
{pending ? 'Redirecting to Google…' : 'Continue with Google'}
{pending ? <LoaderCircle className={s.spin} aria-hidden="true" /> : <Icon />}
{pending ? `Redirecting to ${label}…` : `Continue with ${label}`}
</a>
);
}
67 changes: 52 additions & 15 deletions web/lib/login.ts
Original file line number Diff line number Diff line change
@@ -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<Record<SignInProvider, string>> = {
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<Record<string, string>> = {
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<Record<string, (provider: string) => 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);
}
53 changes: 46 additions & 7 deletions web/lib/test/login.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>) =>
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: '<script>' })).not.toContain('role="alert"');
});

it('names the provider that withheld a verified email, defaulting to Google', async () => {
expect(await render({ authError: 'unverified_email', authProvider: 'microsoft' })).toMatch(
/role="alert"[^>]*>Microsoft did not provide a verified email address\. Use a Microsoft account/,
);
expect(await render({ authError: 'unverified_email' })).toMatch(
/role="alert"[^>]*>Google did not provide a verified email address/,
);
expect(await render({ authError: 'unverified_email', authProvider: 'okta' })).toMatch(
/role="alert"[^>]*>Google did not provide a verified email address/,
);
});

it('shows every agent it works with', async () => {
const html = await render({});
expect(html).toContain('Works with all your agents');
Expand All @@ -42,15 +78,18 @@ describe('login page', () => {
});
});

describe('googleSignInHref', () => {
it('omits empty params', () => {
describe('sign-in hrefs', () => {
it('omit empty params', () => {
expect(googleSignInHref({})).toBe('/cloud/api/auth/google/start');
expect(microsoftSignInHref({})).toBe('/cloud/api/auth/microsoft/start');
});
});

describe('authErrorMessage', () => {
it('returns null without a code or for inherited keys', () => {
expect(authErrorMessage(undefined)).toBeNull();
expect(authErrorMessage('constructor')).toBeNull();
expect(authErrorMessage('constructor', 'microsoft')).toBeNull();
expect(authErrorMessage('unverified_email', 'constructor')).toContain('Google did not');
});
});
Loading