diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 2e76eadb8f..57cfa48f80 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -50,6 +50,7 @@ module.exports = { 'src/v2/components/V2ResetPassword.tsx', 'src/v2/components/V2OAuthButtons.tsx', 'src/v2/components/V2OAuthComplete.tsx', + 'src/v2/components/V2DiscordCallback.tsx', 'src/v2/components/V2DeleteAccount.tsx', 'src/v2/components/V2InviteRedeem.tsx', 'src/components/RegistrationInviteRequired.tsx', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9344cefdd..c748fdc4a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,7 +35,7 @@ import { V2PrivacyPage, V2TermsPage } from './v2/legal/V2LegalPage'; const GuidePage = React.lazy(() => import('./components/landing/GuidePage')); const GuidesIndexPage = React.lazy(() => import('./components/landing/GuidesIndexPage')); const UseCasePage = React.lazy(() => import('./components/landing/UseCasePage')); -const DiscordCallback = React.lazy(() => import('./components/DiscordCallback')); +const V2DiscordCallback = React.lazy(() => import('./v2/components/V2DiscordCallback')); const V2App = React.lazy(() => import('./v2/V2App')); const V2LandingPage = React.lazy(() => import('./v2/landing/V2LandingPage')); const V2CliAuthorize = React.lazy(() => import('./v2/components/V2CliAuthorize')); @@ -310,9 +310,9 @@ function App(): React.ReactElement { answers with `verifyUrl: /cli/authorize`. Deleting this route 404'd every `commonly login` (#1534, 2026-09-04 → 09-08). */} } /> - } /> - } /> - } /> + } /> + } /> + } /> diff --git a/frontend/src/components/DiscordCallback.tsx b/frontend/src/components/DiscordCallback.tsx deleted file mode 100644 index 39ad3bd2d7..0000000000 --- a/frontend/src/components/DiscordCallback.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { - Box, - Button, - Typography, - Paper, - Alert, - CircularProgress, - FormControl, - InputLabel, - Select, - MenuItem, - Divider, -} from '@mui/material'; -import { CheckCircle, Error, ArrowBack } from '@mui/icons-material'; -import axios from 'axios'; - -interface Channel { - id: string; - name: string; - topic?: string; -} - -interface ServerInfo { - id: string; - name: string | null; -} - -interface DiscordCallbackProps { - type?: 'callback' | 'success' | 'error'; -} - -const DiscordCallback: React.FC = ({ type = 'callback' }) => { - const location = useLocation(); - const navigate = useNavigate(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [success, setSuccess] = useState(''); - const [channels, setChannels] = useState([]); - const [selectedChannel, setSelectedChannel] = useState(''); - const [serverInfo, setServerInfo] = useState(null); - - const urlParams = new URLSearchParams(location.search); - const podId = urlParams.get('pod_id'); - const guildId = urlParams.get('guild_id'); - const serverName = urlParams.get('server_name'); - const errorMessage = urlParams.get('error'); - - useEffect(() => { - if (type === 'success' && guildId) { - setServerInfo({ id: guildId, name: serverName }); - fetchChannels(guildId); - } else if (type === 'error') { - setError(errorMessage || 'Discord authorization failed'); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [type, guildId, serverName, errorMessage]); - - const fetchChannels = async (id: string): Promise => { - try { - setLoading(true); - const token = localStorage.getItem('token'); - const response = await axios.get(`/api/discord/channels/${id}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - setChannels(response.data); - } catch (err) { - console.error('Error fetching channels:', err); - setError('Failed to fetch server channels'); - } finally { - setLoading(false); - } - }; - - const handleCreateIntegration = async (): Promise => { - if (!selectedChannel || !podId || !guildId) { - setError('Please select a channel'); - return; - } - - try { - setLoading(true); - setError(''); - - const selectedChannelInfo = channels.find((c) => c.id === selectedChannel); - const token = localStorage.getItem('token'); - - await axios.post( - '/api/integrations', - { - podId, - type: 'discord', - config: { - serverId: guildId, - serverName: serverInfo?.name, - channelId: selectedChannel, - channelName: selectedChannelInfo?.name || 'Unknown Channel', - webhookUrl: '', - botToken: '', - permissions: ['read_messages', 'send_messages', 'read_message_history'], - }, - }, - { headers: { Authorization: `Bearer ${token}` } }, - ); - - setSuccess('Discord integration created successfully!'); - - try { - const podResponse = await axios.get<{ type?: string }>(`/api/pods/${podId}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - const podType = podResponse.data.type || 'chat'; - setTimeout(() => navigate(`/pods/${podType}/${podId}`), 2000); - } catch (podError) { - console.error('Error fetching pod info, defaulting to chat:', podError); - setTimeout(() => navigate(`/pods/chat/${podId}`), 2000); - } - } catch (err: unknown) { - console.error('Error creating integration:', err); - const e = err as { response?: { data?: { message?: string } } }; - setError(e.response?.data?.message || 'Failed to create integration'); - } finally { - setLoading(false); - } - }; - - if (type === 'error') { - return ( - - - - - Discord Authorization Failed - - - {errorMessage || 'Something went wrong during Discord authorization.'} - - - - - ); - } - - if (type === 'success') { - return ( - - - - - - Discord Bot Added Successfully! - - - Server: {serverInfo?.name} - - - - - - {error && {error}} - {success && {success}} - - {!success && ( - <> - - Select Channel for Integration - - - - Channel - - - - - - - - - )} - - - ); - } - - return ( - - - - Processing Discord Authorization... - - Please wait while we set up your Discord integration. - - - - ); -}; - -export default DiscordCallback; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 59842da9f5..91052e7cf7 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -429,8 +429,26 @@ "continueWith": "Continue with {{provider}}" }, "oauthComplete": { - "title": "Signing you in…", - "subtitle": "Completing sign-in with your provider." + "title": "Signing you in…" + }, + "discordHandoff": { + "workingTitle": "Connecting Discord…", + "doneTitle": "Pick a channel", + "connectedTitle": "Discord connected", + "errorTitle": "Discord didn't connect", + "errorFallback": "Something went wrong during Discord authorization.", + "server": "Server", + "channel": "Channel", + "choose": "Choose a channel", + "loadingChannels": "Loading channels…", + "cancel": "Cancel", + "connect": "Connect", + "connecting": "Connecting…", + "back": "Back", + "pickChannel": "Please select a channel", + "channelsFailed": "Failed to fetch server channels", + "createFailed": "Failed to create integration", + "created": "Discord integration created successfully!" }, "forgot": { "title": "Reset your password", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 57fc27502a..50fba76ec9 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -428,8 +428,26 @@ "continueWith": "使用 {{provider}} 继续" }, "oauthComplete": { - "title": "正在登录…", - "subtitle": "正在完成第三方账号登录。" + "title": "正在登录…" + }, + "discordHandoff": { + "workingTitle": "正在连接 Discord…", + "doneTitle": "选择一个频道", + "connectedTitle": "Discord 已连接", + "errorTitle": "Discord 连接失败", + "errorFallback": "Discord 授权时出了问题。", + "server": "服务器", + "channel": "频道", + "choose": "选择频道", + "loadingChannels": "正在加载频道…", + "cancel": "取消", + "connect": "连接", + "connecting": "正在连接…", + "back": "返回", + "pickChannel": "请选择一个频道", + "channelsFailed": "没能获取服务器的频道", + "createFailed": "没能创建集成", + "created": "Discord 集成已创建。" }, "forgot": { "title": "重置密码", diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 83a1c3d3d5..037121f0c3 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -16,11 +16,13 @@ if (process.env.REACT_APP_SENTRY_DSN) { // and a class added in V2App's useEffect wins only AFTER first paint, so // every v2 load still flashed dark for a frame (Sam, 2026-08-24). '/' and // '/v2*' are the v2 front door (and /cli/authorize, a v2 page mounted outside -// V2App that keeps the class itself); V2App's effect keeps the class in sync for -// SPA navigation after boot. Legacy deep links that redirect into /v2 may +// V2App that keeps the class itself; and the /discord/* return pages, which +// redirect into /v2 on the first effect); V2App's effect keeps the class in +// sync for SPA navigation after boot. Legacy deep links that redirect into /v2 may // still flash once — the exception, not the entry path. const bootPath = window.location.pathname; -if (bootPath === '/' || bootPath.startsWith('/v2') || /^\/cli\/authorize\/?$/.test(bootPath)) { +if (bootPath === '/' || bootPath.startsWith('/v2') || /^\/cli\/authorize\/?$/.test(bootPath) + || /^\/discord\/(?:callback|success|error)\/?$/.test(bootPath)) { document.body.classList.add('v2-canvas'); } if (bootPath === '/guides' || bootPath.startsWith('/guides/')) { diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 529e4c8d7a..fd50f909a6 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -7,7 +7,6 @@ import { useAuth } from '../context/AuthContext'; import { useTimeZoneCapture } from './hooks/useTimeZoneCapture'; import RegistrationInviteRequired from '../components/RegistrationInviteRequired'; import VerifyEmail from '../components/VerifyEmail'; -import DiscordCallback from '../components/DiscordCallback'; import V2EmailVerificationBanner from './components/V2EmailVerificationBanner'; import ProtectedRoute from '../components/ProtectedRoute'; import '@fontsource-variable/bricolage-grotesque'; @@ -42,6 +41,7 @@ const V2Login = React.lazy(() => import('./components/V2Login')); const V2PrimitivesPage = React.lazy(() => import('./primitives/V2PrimitivesPage')); const V2Register = React.lazy(() => import('./components/V2Register')); const V2OAuthComplete = React.lazy(() => import('./components/V2OAuthComplete')); +const V2DiscordCallback = React.lazy(() => import('./components/V2DiscordCallback')); const V2ForgotPassword = React.lazy(() => import('./components/V2ForgotPassword')); const V2ResetPassword = React.lazy(() => import('./components/V2ResetPassword')); const V2YourTeamPage = React.lazy(() => import('./components/V2YourTeamPage')); @@ -260,9 +260,9 @@ const V2App: React.FC = () => { } /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> {/* Community access is resolved by the membership-gated pod probe. Keep this outside V2RequireAuth so an anonymous 401 can fall through to the public invite preview instead of the login wall. */} diff --git a/frontend/src/v2/__tests__/V2DiscordCallback.test.tsx b/frontend/src/v2/__tests__/V2DiscordCallback.test.tsx new file mode 100644 index 0000000000..8740df52be --- /dev/null +++ b/frontend/src/v2/__tests__/V2DiscordCallback.test.tsx @@ -0,0 +1,158 @@ +// @ts-nocheck +import React from 'react'; +import { render, screen, act, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import V2DiscordCallback from '../components/V2DiscordCallback'; +import i18n, { i18nReady } from '../../i18n'; + +jest.mock('axios', () => { + const mock = { + get: jest.fn(() => Promise.resolve({ data: [] })), + post: jest.fn(() => Promise.resolve({ data: {} })), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { __esModule: true, default: mock, ...mock }; +}); + +const axios = jest.requireMock('axios').default; + +const Where = () =>

{`at ${useLocation().pathname}`}

; + +// /from is the page the person left for Discord, so Back / Cancel (-1) lands there. +const renderAt = (url) => { + const type = url.startsWith('/discord/success') ? 'success' : url.startsWith('/discord/error') ? 'error' : undefined; + return render( + + + } /> + } /> + + , + ); +}; + +const wire = (container) => container.querySelector('.v2-handoff__wire'); + +beforeAll(async () => { + await i18nReady; +}); + +afterEach(async () => { + jest.clearAllMocks(); + jest.useRealTimers(); + localStorage.clear(); + await i18n.changeLanguage('en'); +}); + +describe('V2DiscordCallback (d215 s-handoff)', () => { + test('callback is the working card: one status line and a running wire, no subtitle', () => { + const { container } = renderAt('/discord/callback'); + expect(screen.getByRole('status')).toHaveTextContent('Connecting Discord…'); + expect(wire(container)).toHaveClass('v2-handoff__wire--working'); + expect(wire(container).querySelector('.v2-handoff__run')).not.toBeNull(); + expect(container.querySelector('.v2-login__subtitle')).toBeNull(); + expect(container.querySelector('.v2-handoff__tile--discord svg')).not.toBeNull(); + }); + + test('error shows the server line as plain text, an ink cross, and Back goes to the previous page', () => { + const { container } = renderAt(`/discord/error?error=${encodeURIComponent('Invalid state parameter')}`); + expect(screen.getByRole('heading', { name: "Discord didn't connect" })).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Invalid state parameter'); + expect(screen.getByRole('alert').querySelector('b')).toBeNull(); + expect(wire(container)).toHaveClass('v2-handoff__wire--error'); + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(screen.getByText('at /from')).toBeInTheDocument(); + }); + + test('error with no server line falls back to the generic one', () => { + renderAt('/discord/error'); + expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong during Discord authorization.'); + }); + + test('success asks for the channel, posts the same integration and moves to the pod after 2s', async () => { + jest.useFakeTimers(); + localStorage.setItem('token', 'jwt'); + axios.get.mockImplementation((url) => (url === '/api/discord/channels/112233445566778899' + ? Promise.resolve({ data: [{ id: '112233445566778801', name: 'general', topic: 'chat' }, { id: '112233445566778802', name: 'ops' }] }) + : Promise.resolve({ data: { type: 'study' } }))); + const { container } = renderAt('/discord/success?pod_id=p1&guild_id=112233445566778899&server_name=Northwind%20HQ'); + + expect(screen.getByRole('heading', { name: 'Pick a channel' })).toBeInTheDocument(); + expect(wire(container)).toHaveClass('v2-handoff__wire--done'); + expect(container.querySelector('.v2-handoff__facts')).toHaveTextContent('ServerNorthwind HQ'); + expect(axios.get).toHaveBeenCalledWith('/api/discord/channels/112233445566778899', { headers: { Authorization: 'Bearer jwt' } }); + const select = await screen.findByRole('combobox', { name: 'Channel' }); + await screen.findByRole('option', { name: '#general - chat' }); + // Starts on the placeholder, not on the first channel. + expect(select).toHaveValue(''); + expect(screen.getByRole('option', { name: 'Choose a channel' })).toBeDisabled(); + const connect = screen.getByRole('button', { name: 'Connect' }); + expect(connect).toBeDisabled(); + + fireEvent.change(select, { target: { value: '112233445566778802' } }); + expect(connect).toBeEnabled(); + await act(async () => { fireEvent.click(connect); }); + + expect(axios.post).toHaveBeenCalledWith('/api/integrations', { + podId: 'p1', + type: 'discord', + config: { + serverId: '112233445566778899', + serverName: 'Northwind HQ', + channelId: '112233445566778802', + channelName: 'ops', + webhookUrl: '', + botToken: '', + permissions: ['read_messages', 'send_messages', 'read_message_history'], + }, + }, { headers: { Authorization: 'Bearer jwt' } }); + expect(screen.getByRole('heading', { name: 'Discord connected' })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Discord integration created successfully!')); + expect(screen.queryByRole('combobox')).toBeNull(); + act(() => { jest.advanceTimersByTime(2000); }); + expect(screen.getByText('at /pods/study/p1')).toBeInTheDocument(); + }); + + test('a failed channel fetch and a failed create each land in the alert row', async () => { + axios.get.mockRejectedValueOnce(new Error('nope')); + jest.spyOn(console, 'error').mockImplementation(() => {}); + renderAt('/discord/success?pod_id=p1&guild_id=g1&server_name=S'); + expect(await screen.findByRole('alert')).toHaveTextContent('Failed to fetch server channels'); + }); + + test('the server message from a failed create shows, and the keys stay', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + axios.get.mockResolvedValueOnce({ data: [{ id: 'c1', name: 'general' }] }); + axios.post.mockRejectedValueOnce({ response: { data: { message: 'Integration exists' } } }); + renderAt('/discord/success?pod_id=p1&guild_id=g1&server_name=S'); + const select = await screen.findByRole('combobox', { name: 'Channel' }); + await screen.findByRole('option', { name: '#general' }); + fireEvent.change(select, { target: { value: 'c1' } }); + await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Connect' })); }); + expect(screen.getByRole('alert')).toHaveTextContent('Integration exists'); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + // The pages are reached through the route tables, which pick the view by prop. + // A swapped success/error mapping would render the wrong page for the backend's + // redirect, so both tables are read and their mapping pinned. + test.each([['App.tsx', '../../App.tsx', '/discord/'], ['V2App.tsx', '../V2App.tsx', 'discord/']])('%s maps each Discord return path to its view', (_name, rel, prefix) => { + // eslint-disable-next-line global-require + const src = require('fs').readFileSync(require('path').resolve(__dirname, rel), 'utf8'); + const route = (path) => (src.match(new RegExp(`}`)) || [])[1]; + expect(route('callback')?.trim()).toBe(''); + expect(route('success')?.trim()).toBe('type="success"'); + expect(route('error')?.trim()).toBe('type="error"'); + }); + + test('reads in Chinese under zh-CN', async () => { + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + renderAt('/discord/error'); + expect(screen.getByRole('heading', { name: 'Discord 连接失败' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '返回' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/v2/__tests__/V2OAuth.test.tsx b/frontend/src/v2/__tests__/V2OAuth.test.tsx index 982afd9b3e..539fb9a139 100644 --- a/frontend/src/v2/__tests__/V2OAuth.test.tsx +++ b/frontend/src/v2/__tests__/V2OAuth.test.tsx @@ -209,6 +209,21 @@ describe('V2OAuthComplete', () => { Object.defineProperty(window, 'location', { configurable: true, value: originalLocation }); }); + test('while the code is exchanged it is the d215 handoff working card: one status line, no subtitle', async () => { + axios.post.mockReturnValueOnce(new Promise(() => {})); + let view; + await act(async () => { view = renderComplete('?code=one-time&next=/v2'); }); + expect(screen.getByRole('status')).toHaveTextContent('Signing you in…'); + const wire = view.container.querySelector('.v2-handoff__wire'); + expect(wire).toHaveClass('v2-handoff__wire--working'); + expect(wire.querySelector('.v2-handoff__run')).not.toBeNull(); + // The return URL does not name the provider: a plain tile, never a guessed logo. + expect(view.container.querySelector('.v2-handoff__provider svg')).not.toBeNull(); + expect(view.container.querySelector('.v2-handoff__tile')).toBeNull(); + expect(view.container.querySelector('.v2-login__subtitle')).toBeNull(); + expect(view.container.querySelector('.v2-login__card--stack')).not.toBeNull(); + }); + test('exchanges the one-time code, hands the session to AuthContext and navigates in-app', async () => { axios.post.mockResolvedValueOnce({ data: { token: 'session-jwt' } }); const signInWithToken = jest.fn(); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 2d680a658d..ccb97fec57 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -6270,3 +6270,38 @@ describe('zh-CN labels fit their keys at 390 (content-sized, hit areas overhang expect(hint).not.toMatch(fixedWidth); }); }); + +describe('sign-in handoff drawn from the d215 s-handoff frames', () => { + const css = readV2Css(); + const tsx = (rel: string) => read(`../components/${rel}`); + + test('the wire tells the state: dotted with a running dot, solid when done, an ink knot on error', () => { + expect(ruleBody(css, '.v2-handoff__wire::before')).toContain('border-top: 2px dotted var(--v2-border)'); + expect(ruleBody(css, '.v2-handoff__wire--done::before')).toContain('border-top: 2px solid var(--v2-accent)'); + const run = ruleBody(css, '.v2-handoff__run'); + expect(run).toContain('background: var(--v2-accent)'); + expect(run).toContain('animation: v2-handoff-run 1.4s ease-in-out infinite'); + expect(css).toMatch(/@keyframes v2-handoff-run \{[^@]*100% \{ left: calc\(100% - 6px\); opacity: 0; \}/); + expect(ruleBody(css, '.v2-handoff__wire--error .v2-handoff__knot')).toContain('box-shadow: inset 0 0 0 1.5px var(--v2-text-primary)'); + // Reduced motion: the dot stops mid-wire instead of vanishing. + expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\) \{\s*\.v2-handoff__run \{ animation: none; left: calc\(50% - 3px\); \}/); + }); + + test('the done card keeps its blocks: fact row, a caret select, two keys in one row', () => { + const facts = ruleBody(css, '.v2-handoff__facts dd'); + expect(facts).toContain('overflow-wrap: anywhere'); + expect(facts).toContain('min-width: 0'); + expect(ruleBody(css, '.v2-handoff__keys')).toContain('grid-template-columns: 1fr 1fr'); + const select = ruleBody(css, '.v2-root select.v2-login__input'); + expect(select).toContain('appearance: none'); + expect(select).toContain('width: 100%'); + expect(ruleBody(css, '.v2-handoff__select > svg')).toContain('pointer-events: none'); + }); + + test('the Discord return pages sit in the auth family scroller, and /discord/* reaches V2App', () => { + expect(tsx('V2DiscordCallback.tsx')).toMatch(/
\s*
/); + // The backend redirects to /discord/*; without this mapping the card renders outside .v2-root, unstyled. + expect(read('../../utils/legacyV2Path.ts')).toContain("if (pathname.startsWith('/discord/')) return `/v2${pathname}${search}`;"); + expect(read('../../index.tsx')).toContain('/^\\/discord\\/(?:callback|success|error)\\/?$/.test(bootPath)'); + }); +}); diff --git a/frontend/src/v2/components/V2AuthBrand.tsx b/frontend/src/v2/components/V2AuthBrand.tsx index 7b4b9e3359..c29f1f1bca 100644 --- a/frontend/src/v2/components/V2AuthBrand.tsx +++ b/frontend/src/v2/components/V2AuthBrand.tsx @@ -1,16 +1,43 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { ShellGlyph } from '../icons/glyphs'; + +export type V2HandoffState = 'working' | 'done' | 'error'; + +// The d215 s-handoff brand row: the provider's tile, a wire, then the mark. +// Working, a cobalt dot runs the dotted wire; done, the wire is solid with a +// check; error, an ink cross sits on it. The wire is decoration: the page's +// title says the state in words. +export interface V2AuthHandoff { + state: V2HandoffState; + provider: React.ReactNode; +} // The one brand row for auth-card pages (login / register / forgot / reset / -// oauth-complete). Same mark as the nav rail and the landing page — the -// open-C with three dots — NOT a letter "c" (the old auth pages drifted to a -// plain letter, which read as a different logo next to every other surface). -// Wordmark stays lowercase to match the rail (app chrome, not marketing). -const V2AuthBrand: React.FC = () => { +// oauth-complete / the Discord return). Same mark as the nav rail and the +// landing page — the open-C with three dots — NOT a letter "c" (the old auth +// pages drifted to a plain letter, which read as a different logo next to +// every other surface). Wordmark stays lowercase to match the rail (app +// chrome, not marketing). +const V2AuthBrand: React.FC<{ handoff?: V2AuthHandoff }> = ({ handoff }) => { const { t } = useTranslation(); return ( -
+
+ {handoff ? ( + <> + {handoff.provider} +