diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 9550e589f..cc609b905 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -2,12 +2,16 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { describe, expect, it } from 'vitest'; import { App } from './App'; +import { fakeCoreKitSession, fakeEngineClient, pageWrapper } from './test/authFakes'; function renderAt(path: string) { + const Providers = pageWrapper(fakeEngineClient().client, fakeCoreKitSession().session); return render( - - - + + + + + ); } diff --git a/apps/web/src/auth/CoreKitProvider.tsx b/apps/web/src/auth/CoreKitProvider.tsx new file mode 100644 index 000000000..781ed1ac0 --- /dev/null +++ b/apps/web/src/auth/CoreKitProvider.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; +import { errorMessage } from '../lib/errorMessage'; +import type { CoreKitSession } from './coreKit'; + +export interface CoreKitContextValue { + /** `null` until the session is built and its restore attempt has settled. */ + session: CoreKitSession | null; + /** True while the mount-time session restore is still in flight. */ + isRestoring: boolean; + /** Why Core Kit is unusable at all — a missing or rejected build config. */ + error: string | null; +} + +const CoreKitContext = createContext(undefined); + +export interface CoreKitProviderProps { + /** Builds this tab's Core Kit session. Called once per tab. */ + createSession: () => CoreKitSession; + children: ReactNode; +} + +/** + * Owns the tab's one Core Kit session and its mount-time restore. The session + * and the restore promise are both latched in refs: the SDK holds a device + * factor in origin storage, so a StrictMode remount must reuse the instance + * rather than race a second one against the same store. + */ +export function CoreKitProvider({ createSession, children }: CoreKitProviderProps) { + const [value, setValue] = useState({ + session: null, + isRestoring: true, + error: null, + }); + const factory = useRef(createSession); + const session = useRef(null); + const restore = useRef | null>(null); + + useEffect(() => { + let live = true; + try { + session.current ??= factory.current(); + restore.current ??= session.current.restore(); + } catch (error) { + setValue({ session: null, isRestoring: false, error: errorMessage(error) }); + return; + } + + const settled = { session: session.current, isRestoring: false, error: null }; + // A failed restore just means there is no session to resume; the methods + // below still work, and a real breakage surfaces when one is used. + restore.current.then( + () => live && setValue(settled), + () => live && setValue(settled) + ); + + return () => { + live = false; + }; + }, []); + + return {children}; +} + +/** This tab's Core Kit session and its restore state. */ +export function useCoreKit(): CoreKitContextValue { + const value = useContext(CoreKitContext); + if (!value) throw new Error('auth hooks must be used within '); + return value; +} diff --git a/apps/web/src/auth/coreKit.ts b/apps/web/src/auth/coreKit.ts new file mode 100644 index 000000000..4cfe29146 --- /dev/null +++ b/apps/web/src/auth/coreKit.ts @@ -0,0 +1,106 @@ +/** + * Web3Auth Core Kit on the UI thread: it owns its own popup and redirect flows, + * so it cannot live in the engine worker (blueprint/web-client.md "Login and + * identity"). The one thing it produces that the vault cares about is the login + * secret, which `engine/loginHandoff` transfers to the engine. + */ + +import { COREKIT_STATUS, WEB3AUTH_NETWORK, Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit'; +import { tssLib } from '@toruslabs/tss-dkls-lib'; +import { environment } from '../engine/config'; +import type { LoginSecretExporter } from '../engine/loginHandoff'; + +/** How a session was established; also the `authStore` login method. */ +export type CoreKitLoginMethod = 'google' | 'email'; + +/** + * The Core Kit surface the login flow drives. Narrow by construction: the hook + * never sees a Web3Auth parameter shape, and a test substitutes a plain object. + */ +export interface CoreKitSession extends LoginSecretExporter { + /** Restores a prior session, if the SDK has one on this device. */ + restore(): Promise; + /** True once a login (or a restore) has completed on this device. */ + isLoggedIn(): boolean; + login(method: CoreKitLoginMethod, email?: string): Promise; + /** How the live session was established, as Core Kit reports it. */ + method(): CoreKitLoginMethod; + /** The signed-in user's email, when the method carries one. */ + email(): string | null; + logout(): Promise; +} + +/** Adapts the Web3Auth SDK to the narrow session seam above. */ +class Web3AuthSession implements CoreKitSession { + constructor( + private readonly coreKit: Web3AuthMPCCoreKit, + private readonly verifier: string, + private readonly clientId: string + ) {} + + async restore(): Promise { + await this.coreKit.init(); + } + + isLoggedIn(): boolean { + return this.coreKit.status === COREKIT_STATUS.LOGGED_IN; + } + + async login(method: CoreKitLoginMethod, email?: string): Promise { + await this.coreKit.loginWithOAuth({ + subVerifierDetails: { + typeOfLogin: method === 'google' ? 'google' : 'email_passwordless', + verifier: this.verifier, + clientId: this.clientId, + ...(email ? { jwtParams: { login_hint: email } } : {}), + }, + }); + if (this.coreKit.status !== COREKIT_STATUS.LOGGED_IN) { + // REQUIRED_SHARE: MFA is on and this device holds no factor. Recovery and + // device approval are not built yet, so fail rather than half-log-in, and + // end the partial session rather than leave it resident on the device. + await this.coreKit.logout().catch(() => undefined); + throw new Error('this device needs approval or a recovery phrase before it can sign in'); + } + await this.coreKit.commitChanges(); + } + + method(): CoreKitLoginMethod { + return this.coreKit.getUserInfo().typeOfLogin === 'google' ? 'google' : 'email'; + } + + email(): string | null { + return this.coreKit.getUserInfo().email ?? null; + } + + async logout(): Promise { + if (this.isLoggedIn()) await this.coreKit.logout(); + } + + _UNSAFE_exportTssKey(): Promise { + return this.coreKit._UNSAFE_exportTssKey(); + } +} + +/** Builds this tab's Core Kit session from the build-time environment. */ +export function createCoreKitSession(env: Partial): CoreKitSession { + const clientId = env.VITE_WEB3AUTH_CLIENT_ID; + const verifier = env.VITE_WEB3AUTH_VERIFIER; + if (!clientId || !verifier) { + throw new Error('VITE_WEB3AUTH_CLIENT_ID and VITE_WEB3AUTH_VERIFIER must both be configured'); + } + + const coreKit = new Web3AuthMPCCoreKit({ + web3AuthClientId: clientId, + web3AuthNetwork: + environment(env) === 'production' ? WEB3AUTH_NETWORK.MAINNET : WEB3AUTH_NETWORK.DEVNET, + // Core Kit persists its own device-factor share and session id here. The + // login secret is not among them — it only ever leaves this realm as the + // transferred buffer — but this store is a bearer path back to a logged-in + // Core Kit, so its scope is a decision, not a default: see #913. + storage: window.localStorage, + manualSync: true, + tssLib, + }); + return new Web3AuthSession(coreKit, verifier, clientId); +} diff --git a/apps/web/src/auth/siweNonce.test.ts b/apps/web/src/auth/siweNonce.test.ts new file mode 100644 index 000000000..c14cd65cf --- /dev/null +++ b/apps/web/src/auth/siweNonce.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { requestSiweNonce } from './siweNonce'; + +// A fresh Response per call: a body can only be read once. +function respond(body: unknown, status = 200) { + return vi.spyOn(globalThis, 'fetch').mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) + ) + ); +} + +describe('requestSiweNonce', () => { + afterEach(() => vi.restoreAllMocks()); + + it('posts to the API challenge endpoint under a timeout and returns the nonce', async () => { + const fetchSpy = respond({ nonce: 'abc12345', expiresAt: '2026-01-01T00:00:00.000Z' }); + + await expect(requestSiweNonce('https://api.test')).resolves.toBe('abc12345'); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(String(url)).toBe('https://api.test/auth/siwe/challenge'); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + + it('resolves the endpoint against a base with a path or a trailing slash', async () => { + const fetchSpy = respond({ nonce: 'abc12345' }); + + await requestSiweNonce('https://api.test/'); + await requestSiweNonce('https://api.test/ignored'); + + for (const [url] of fetchSpy.mock.calls) { + expect(String(url)).toBe('https://api.test/auth/siwe/challenge'); + } + }); + + it('refuses a nonce too weak, too long, or too malformed to sign over', async () => { + respond({ nonce: 'short' }); + await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); + + respond({ nonce: 'not a nonce!' }); + await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); + + // A hostile API must not push an unbounded string into the signing prompt. + respond({ nonce: 'a'.repeat(129) }); + await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); + + respond({}); + await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); + }); + + it('surfaces a refused challenge by status, without echoing its body', async () => { + respond({ message: '' }, 429); + await expect(requestSiweNonce('https://api.test')).rejects.toThrow( + /^siwe challenge refused with 429$/ + ); + }); +}); diff --git a/apps/web/src/auth/siweNonce.ts b/apps/web/src/auth/siweNonce.ts new file mode 100644 index 000000000..4d43da10c --- /dev/null +++ b/apps/web/src/auth/siweNonce.ts @@ -0,0 +1,28 @@ +/** + * The single-use nonce an EIP-4361 message must carry. The engine owns the SIWE + * exchange itself (`facade.siweLogin`) but exposes no challenge command, so the + * page fetches the nonce from the API's public challenge endpoint — see #910 to + * move this below the facade. + */ + +/** EIP-4361 requires 8+ alphanumerics; the API issues 32 hex characters. */ +const NONCE = /^[A-Za-z0-9]{8,128}$/; + +const TIMEOUT_MS = 10_000; + +export async function requestSiweNonce(apiBaseUrl: string): Promise { + const response = await fetch(new URL('/auth/siwe/challenge', apiBaseUrl), { + method: 'POST', + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`siwe challenge refused with ${response.status}`); + + const { nonce } = (await response.json()) as { nonce?: unknown }; + // Fail closed: an unusable nonce must not reach the wallet as a signing + // prompt, and its character class is what keeps a hostile response from + // injecting extra EIP-4361 fields into the signed text. + if (typeof nonce !== 'string' || !NONCE.test(nonce)) { + throw new Error('siwe challenge returned an unusable nonce'); + } + return nonce; +} diff --git a/apps/web/src/auth/useAuth.test.tsx b/apps/web/src/auth/useAuth.test.tsx new file mode 100644 index 000000000..3bda12088 --- /dev/null +++ b/apps/web/src/auth/useAuth.test.tsx @@ -0,0 +1,200 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { authStore } from '../stores/auth.store'; +import { authWrapper, fakeCoreKitSession, fakeEngineClient, SECRET_HEX } from '../test/authFakes'; +import { useLoginSecretSource } from '../providers/EngineProvider'; +import { useAuth } from './useAuth'; + +const SECRET_BYTES = Uint8Array.from({ length: 32 }, () => 0x0f); + +/** Mounts `useAuth` alongside the secret source the failover path re-exports through. */ +function mount( + client: ReturnType, + coreKit: ReturnType +) { + return renderHook(() => ({ auth: useAuth(), secrets: useLoginSecretSource() }), { + wrapper: authWrapper(client.client, coreKit.session), + }); +} + +describe('useAuth', () => { + beforeEach(() => authStore.signedOut()); + + it('drives the Core Kit google flow and hands the engine the login secret', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(() => result.current.auth.loginWithGoogle()); + + expect(coreKit.calls.logins).toEqual([{ method: 'google', email: undefined }]); + expect(engine.calls.secrets).toEqual([SECRET_BYTES]); + expect(authStore.getState()).toMatchObject({ + isAuthenticated: true, + method: 'google', + email: 'user@example.test', + }); + }); + + it('passes the typed address to the Core Kit email flow before the handoff', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(() => result.current.auth.loginWithEmail('user@example.test')); + + expect(coreKit.calls.logins).toEqual([{ method: 'email', email: 'user@example.test' }]); + expect(engine.calls.secrets).toEqual([SECRET_BYTES]); + }); + + it('routes a wallet signature to the facade and exports no secret for it', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + const signature = new Uint8Array(65).fill(7); + await act(() => result.current.auth.loginWithWallet('siwe-message', signature)); + + expect(engine.calls.siwe).toEqual([{ message: 'siwe-message', signature }]); + expect(engine.calls.started).toEqual([]); + expect(coreKit.calls.exports).toBe(0); + expect(authStore.getState()).toMatchObject({ isAuthenticated: true, method: 'wallet' }); + }); + + it('tears down the engine and the Core Kit session on logout', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + await act(() => result.current.auth.loginWithGoogle()); + const secrets = result.current.secrets!; + + await act(() => result.current.auth.logout()); + + expect(engine.calls.logouts).toBe(1); + expect(coreKit.calls.logouts).toBe(1); + expect(authStore.getState().isAuthenticated).toBe(false); + // The re-export capability must not outlive the session it belonged to. + await expect(secrets.provideSecret()).rejects.toThrow(/no login session/); + }); + + it('tears the Core Kit session down even when the engine refuses to log out', async () => { + const engine = fakeEngineClient({ logout: () => Promise.reject(new Error('engine gone')) }); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(async () => { + await result.current.auth.logout().catch(() => undefined); + }); + + expect(coreKit.calls.logouts).toBe(1); + expect(authStore.getState().isAuthenticated).toBe(false); + expect(result.current.auth.error).toBe('engine gone'); + }); + + it('replaces the closed engine client so the tab can log in again', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + const firstSource = result.current.secrets!; + + await act(() => result.current.auth.logout()); + + // A new secret source means a new client: `facade.logout` closed the old one. + await waitFor(() => expect(result.current.secrets).not.toBe(firstSource)); + expect(result.current.auth.isReady).toBe(true); + }); + + it('hands the secret over for a Core Kit session that survived the reload', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession({ loggedIn: true }); + const { result } = mount(engine, coreKit); + + await waitFor(() => expect(result.current.auth.isAuthenticated).toBe(true)); + + expect(coreKit.calls.logins).toEqual([]); + expect(engine.calls.secrets).toEqual([SECRET_BYTES]); + }); + + it('leaves the tab signed out and disarmed when the engine refuses the secret', async () => { + const engine = fakeEngineClient({ start: () => Promise.reject(new Error('trust violation')) }); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + const secrets = result.current.secrets!; + + await act(async () => { + await result.current.auth.loginWithGoogle().catch(() => undefined); + }); + + expect(authStore.getState().isAuthenticated).toBe(false); + expect(result.current.auth.error).toBe('trust violation'); + await expect(secrets.provideSecret()).rejects.toThrow(/no login session/); + // A Core Kit session the engine refused is ended rather than left resident. + expect(coreKit.calls.logouts).toBe(1); + // The refused buffer is scrubbed rather than left holding the scalar. + expect(new Uint8Array(engine.calls.started[0])).toEqual(new Uint8Array(32)); + }); + + it('disarms the secret source when reading the session metadata throws', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession({ + email: () => { + throw new Error('userNotLoggedIn'); + }, + }); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + const secrets = result.current.secrets!; + + await act(async () => { + await result.current.auth.loginWithGoogle().catch(() => undefined); + }); + + expect(authStore.getState().isAuthenticated).toBe(false); + expect(engine.calls.started).toEqual([]); + await expect(secrets.provideSecret()).rejects.toThrow(/no login session/); + }); + + it('refuses a second sign-in while the first is still in flight', async () => { + let release!: () => void; + const engine = fakeEngineClient({ start: () => new Promise((r) => (release = r)) }); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + let first!: Promise; + act(() => { + first = result.current.auth.loginWithGoogle(); + }); + await waitFor(() => expect(engine.calls.started).toHaveLength(1)); + + await expect(result.current.auth.loginWithGoogle()).rejects.toThrow( + /another sign-in is already in progress/ + ); + expect(coreKit.calls.logins).toHaveLength(1); + + await act(async () => { + release(); + await first; + }); + }); + + it('keeps the login secret out of React state and the auth store', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(() => result.current.auth.loginWithGoogle()); + + const rendered = JSON.stringify({ auth: result.current.auth, store: authStore.getState() }); + expect(rendered).not.toContain(SECRET_HEX); + expect(rendered).not.toContain([...SECRET_BYTES].join(',')); + }); +}); diff --git a/apps/web/src/auth/useAuth.ts b/apps/web/src/auth/useAuth.ts new file mode 100644 index 000000000..bfec8d660 --- /dev/null +++ b/apps/web/src/auth/useAuth.ts @@ -0,0 +1,156 @@ +/** + * The login flow, rewired onto the facade (blueprint/web-client.md "Login and + * identity"). Core Kit authenticates the person on the UI thread; the only + * thing that crosses into the vault is the login secret, transferred once by + * `handOffLoginSecret`. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { handOffLoginSecret } from '../engine/loginHandoff'; +import { errorMessage } from '../lib/errorMessage'; +import { authStore, useAuthState } from '../stores/auth.store'; +import { useEngine, useLoginSecretSource, useRebuildEngine } from '../providers/EngineProvider'; +import { useCoreKit } from './CoreKitProvider'; +import type { CoreKitLoginMethod, CoreKitSession } from './coreKit'; + +export interface Auth { + isAuthenticated: boolean; + /** True while the tab is still assembling its engine or Core Kit session. */ + isReady: boolean; + /** True while a restore, login, or logout is in flight. */ + isBusy: boolean; + /** The last failure, already stripped of anything secret-shaped. */ + error: string | null; + loginWithGoogle(): Promise; + loginWithEmail(email: string): Promise; + /** Exchanges a wallet-signed SIWE message; secondary to the Core Kit methods. */ + loginWithWallet(message: string, signature: Uint8Array): Promise; + logout(): Promise; +} + +/** + * There is one engine per origin and one cold start per tab, so these guards + * are module-scoped: every `useAuth()` consumer drives the same transitions, + * and a second one must not start a second login. + */ +let inFlight = false; +let restoredFor: CoreKitSession | null = null; + +export function useAuth(): Auth { + const client = useEngine(); + const secrets = useLoginSecretSource(); + const rebuildEngine = useRebuildEngine(); + const { session, isRestoring, error: coreKitError } = useCoreKit(); + const { isAuthenticated } = useAuthState(); + + const [isBusy, setIsBusy] = useState(false); + const [error, setError] = useState(null); + + const isReady = client !== null && session !== null && !isRestoring; + + /** Serializes the auth transitions; a collision rejects rather than no-ops. */ + const exclusively = useCallback(async (step: () => Promise): Promise => { + if (inFlight) throw new Error('another sign-in is already in progress'); + inFlight = true; + setIsBusy(true); + setError(null); + try { + await step(); + } catch (failure) { + setError(errorMessage(failure)); + throw failure; + } finally { + inFlight = false; + setIsBusy(false); + } + }, []); + + /** + * The Core Kit → engine handoff. The secret source is armed first so a + * leadership failover mid-start can re-export it; every step after that stays + * inside the failure envelope, so nothing can leave it armed over a UI that + * renders signed out. + */ + const handOff = useCallback(async (): Promise => { + if (!client || !session) throw new Error('the engine is not ready to accept a login'); + const method = session.method(); + const email = session.email(); + + secrets?.use(session); + try { + await handOffLoginSecret(client, session); + authStore.signedIn(method, email); + } catch (failure) { + secrets?.use(null); + // A Core Kit session the engine refused is a live credential on this + // device that nothing in the UI can reach; end it here. + await session.logout().catch(() => undefined); + throw failure; + } + }, [client, secrets, session]); + + const login = useCallback( + (method: CoreKitLoginMethod, email?: string) => + exclusively(async () => { + if (!session) throw new Error('the login provider is not ready'); + await session.login(method, email); + await handOff(); + }), + [exclusively, handOff, session] + ); + + const loginWithGoogle = useCallback(() => login('google'), [login]); + const loginWithEmail = useCallback((email: string) => login('email', email), [login]); + + const loginWithWallet = useCallback( + (message: string, signature: Uint8Array) => + exclusively(async () => { + if (!client) throw new Error('the engine is not ready to accept a login'); + // Secondary method: this authenticates the account against the API, it + // does not cold-start a vault — the engine refuses it before `start`. + await client.facade.siweLogin(message, signature); + authStore.signedIn('wallet'); + }), + [client, exclusively] + ); + + const logout = useCallback( + () => + exclusively(async () => { + // Every leg runs: a refused engine zeroize must not strand the Core Kit + // session, and a failed Core Kit logout must not leave the UI signed in. + const outcomes = await Promise.allSettled([ + client?.facade.logout() ?? Promise.resolve(), + session?.logout() ?? Promise.resolve(), + ]); + secrets?.use(null); + restoredFor = null; + authStore.signedOut(); + rebuildEngine(); + const failed = outcomes.find((outcome) => outcome.status === 'rejected'); + if (failed) throw failed.reason as Error; + }), + [client, exclusively, rebuildEngine, secrets, session] + ); + + // A Core Kit session that survived the reload still has to hand the engine its + // secret; without this the tab renders logged-out over a live login. + useEffect(() => { + if (!isReady || isAuthenticated || restoredFor === session || !session?.isLoggedIn()) return; + restoredFor = session; + exclusively(handOff).catch(() => { + restoredFor = null; + }); + }, [exclusively, handOff, isAuthenticated, isReady, session]); + + return { + isAuthenticated, + isReady, + isBusy, + error: error ?? coreKitError, + loginWithGoogle, + loginWithEmail, + loginWithWallet, + logout, + }; +} diff --git a/apps/web/src/components/MatrixBackground.tsx b/apps/web/src/components/MatrixBackground.tsx new file mode 100644 index 000000000..cc5b7e4e9 --- /dev/null +++ b/apps/web/src/components/MatrixBackground.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef } from 'react'; + +const FONT_SIZE = 14; +const COLUMN_WIDTH = 20; +const FRAME_INTERVAL_MS = 50; +const OPACITY = 0.3; +const CHARACTERS = '01'; +const PRIMARY_COLOR = '#00D084'; +const DIM_COLOR = '#006644'; + +/** Decorative falling-bits canvas behind the login panel. */ +export function MatrixBackground() { + const canvasRef = useRef(null); + const animationRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext('2d'); + if (!canvas || !context) return; + + let columns: number[] = []; + let lastFrameTime = 0; + let pendingResize = 0; + + const resize = () => { + pendingResize = 0; + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + // Assigning width resets the 2D state, so the font is set here, not per frame. + context.font = `${FONT_SIZE}px "JetBrains Mono", monospace`; + // Stagger the starts above the viewport so columns do not fall in lockstep. + columns = Array.from({ length: Math.floor(canvas.width / COLUMN_WIDTH) }, () => + Math.floor(Math.random() * -100) + ); + }; + + // A drag fires resize events far faster than frames; collapse them to one. + const onResize = () => { + pendingResize ||= requestAnimationFrame(resize); + }; + + const draw = (timestamp: number) => { + animationRef.current = requestAnimationFrame(draw); + if (timestamp - lastFrameTime < FRAME_INTERVAL_MS) return; + lastFrameTime = timestamp; + + // Fade the previous frame rather than clearing it: that is the trail. + context.fillStyle = 'rgba(0, 0, 0, 0.05)'; + context.fillRect(0, 0, canvas.width, canvas.height); + context.fillStyle = PRIMARY_COLOR; + + for (let i = 0; i < columns.length; i++) { + const y = columns[i]++ * FONT_SIZE; + if (y > canvas.height) { + if (Math.random() > 0.975) columns[i] = 0; + continue; + } + + const char = CHARACTERS[Math.floor(Math.random() * CHARACTERS.length)]; + context.fillText(char, i * COLUMN_WIDTH, y); + if (Math.random() > 0.98) { + context.fillStyle = DIM_COLOR; + context.fillText(char, i * COLUMN_WIDTH, y - FONT_SIZE); + context.fillStyle = PRIMARY_COLOR; + } + } + }; + + resize(); + window.addEventListener('resize', onResize); + animationRef.current = requestAnimationFrame(draw); + + return () => { + window.removeEventListener('resize', onResize); + cancelAnimationFrame(pendingResize); + cancelAnimationFrame(animationRef.current); + }; + }, []); + + return ( +