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 ( + + ); +} diff --git a/apps/web/src/components/StagingBanner.tsx b/apps/web/src/components/StagingBanner.tsx new file mode 100644 index 000000000..f9d11d97f --- /dev/null +++ b/apps/web/src/components/StagingBanner.tsx @@ -0,0 +1,16 @@ +import { environment } from '../engine/config'; + +/** Warns that a staging deployment makes no data-safety guarantee. */ +export function StagingBanner() { + if (environment(import.meta.env) !== 'staging') return null; + + return ( + + ⚠ Staging environment ⚠ + + // This is a staging instance for testing purposes only. No guarantees are made regarding + data safety or security. + + + ); +} diff --git a/apps/web/src/components/auth/EmailLoginForm.tsx b/apps/web/src/components/auth/EmailLoginForm.tsx new file mode 100644 index 000000000..8cfd342e4 --- /dev/null +++ b/apps/web/src/components/auth/EmailLoginForm.tsx @@ -0,0 +1,57 @@ +import { useState, type FormEvent } from 'react'; + +interface EmailLoginFormProps { + onLogin: (email: string) => void; + /** True while the tab cannot accept a login at all. */ + disabled?: boolean; + /** True while some auth transition is in flight. */ + busy?: boolean; +} + +/** + * Collects the address Core Kit's passwordless flow sends its code to; the code + * itself is entered in Web3Auth's own window. + */ +export function EmailLoginForm({ onLogin, disabled, busy }: EmailLoginFormProps) { + const [email, setEmail] = useState(''); + const trimmed = email.trim().toLowerCase(); + const blocked = disabled || busy; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (trimmed && !blocked) onLogin(trimmed); + }; + + return ( + + + Email address + + setEmail(event.target.value)} + disabled={blocked} + required + autoComplete="email" + /> + + {busy ? 'sending code...' : '[CONTINUE]'} + + + ); +} diff --git a/apps/web/src/components/auth/GoogleLoginButton.tsx b/apps/web/src/components/auth/GoogleLoginButton.tsx new file mode 100644 index 000000000..b6c61a957 --- /dev/null +++ b/apps/web/src/components/auth/GoogleLoginButton.tsx @@ -0,0 +1,24 @@ +interface GoogleLoginButtonProps { + onLogin: () => void; + /** True while the tab cannot accept a login at all. */ + disabled?: boolean; + /** True while some auth transition is in flight. */ + busy?: boolean; +} + +/** Starts Core Kit's Google flow; Web3Auth owns the popup and the OAuth round-trip. */ +export function GoogleLoginButton({ onLogin, disabled, busy }: GoogleLoginButtonProps) { + return ( + + {busy ? 'authenticating with google...' : '[GOOGLE]'} + + ); +} diff --git a/apps/web/src/components/auth/LoginError.tsx b/apps/web/src/components/auth/LoginError.tsx new file mode 100644 index 000000000..99b0db35b --- /dev/null +++ b/apps/web/src/components/auth/LoginError.tsx @@ -0,0 +1,8 @@ +/** The one error banner the login page and its methods both render. */ +export function LoginError({ message }: { message: string }) { + return ( + + {message} + + ); +} diff --git a/apps/web/src/components/auth/LogoutButton.tsx b/apps/web/src/components/auth/LogoutButton.tsx new file mode 100644 index 000000000..3c5cd417e --- /dev/null +++ b/apps/web/src/components/auth/LogoutButton.tsx @@ -0,0 +1,30 @@ +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../../auth/useAuth'; + +/** Immediate logout, no confirmation. */ +export function LogoutButton() { + const { logout, isBusy } = useAuth(); + const navigate = useNavigate(); + + const signOut = async () => { + try { + await logout(); + } catch { + // `useAuth` already surfaces the failure as `error`. + } finally { + navigate('/'); + } + }; + + return ( + void signOut()} + disabled={isBusy} + > + {isBusy ? 'logging out...' : 'logout'} + + ); +} diff --git a/apps/web/src/components/auth/WalletLoginButton.tsx b/apps/web/src/components/auth/WalletLoginButton.tsx new file mode 100644 index 000000000..75e678571 --- /dev/null +++ b/apps/web/src/components/auth/WalletLoginButton.tsx @@ -0,0 +1,164 @@ +import { useEffect, useRef, useState } from 'react'; +import { useConnect, useDisconnect, useSignMessage } from 'wagmi'; +import { mainnet } from 'wagmi/chains'; +import { hexToBytes } from 'viem'; +import { createSiweMessage } from 'viem/siwe'; +import { requestSiweNonce } from '../../auth/siweNonce'; +import { errorMessage } from '../../lib/errorMessage'; +import { LoginError } from './LoginError'; + +interface WalletLoginButtonProps { + /** Hands the signed EIP-4361 message to the facade. */ + onLogin: (message: string, signature: Uint8Array) => Promise; + apiBaseUrl: string; + disabled?: boolean; +} + +type Phase = 'idle' | 'connecting' | 'signing' | 'verifying'; + +const PHASE_LABEL: Record = { + idle: '[WALLET]', + connecting: 'connecting wallet...', + signing: 'sign the message in your wallet...', + verifying: 'verifying signature...', +}; + +/** + * SIWE, the secondary auth method (blueprint/web-client.md "Login and + * identity"): wagmi collects the wallet signature here and the facade forwards + * it. + */ +export function WalletLoginButton({ onLogin, apiBaseUrl, disabled }: WalletLoginButtonProps) { + const { connectors, connectAsync } = useConnect(); + const { signMessageAsync } = useSignMessage(); + const { disconnect } = useDisconnect(); + + const [phase, setPhase] = useState('idle'); + const [error, setError] = useState(null); + const [picking, setPicking] = useState(false); + + const busy = phase !== 'idle'; + + const trigger = useRef(null); + const picker = useRef(null); + const wasPicking = useRef(false); + + // Opening the picker unmounts the trigger, so keyboard focus has to move with + // it and come back when the picker closes. + useEffect(() => { + if (picking) picker.current?.querySelector('button')?.focus(); + else if (wasPicking.current) trigger.current?.focus(); + wasPicking.current = picking; + }, [picking]); + + const signIn = async (connector: (typeof connectors)[number]) => { + setError(null); + // Past the handoff the facade owns the outcome, and the page renders it; + // this component only reports what went wrong on the wallet's side of it. + let handedOff = false; + try { + setPhase('connecting'); + const { accounts } = await connectAsync({ connector }); + const [account] = accounts; + if (!account) throw new Error('the wallet returned no account'); + + setPhase('signing'); + const message = createSiweMessage({ + address: account, + chainId: mainnet.id, + domain: window.location.host, + nonce: await requestSiweNonce(apiBaseUrl), + uri: window.location.origin, + version: '1', + statement: 'Sign in to CipherBox encrypted storage', + }); + // Pin the account the message names: a mid-flow account switch would + // otherwise sign with one address over a message naming another. + const signature = await signMessageAsync({ account, message }); + + setPhase('verifying'); + handedOff = true; + await onLogin(message, hexToBytes(signature)); + setPicking(false); + } catch (failure) { + if (!handedOff) setError(rejectionOf(failure)); + } finally { + // CipherBox needs the wallet for one signature, never a standing session. + disconnect(); + setPhase('idle'); + } + }; + + // EIP-6963 can announce the same wallet twice; one row per name. + const unique = connectors.filter((c, i, all) => all.findIndex((x) => x.name === c.name) === i); + + return ( + + {picking ? ( + + {busy ? ( + + {PHASE_LABEL[phase]} + + ) : unique.length === 0 ? ( + + no wallets detected. install MetaMask or another browser wallet. + + ) : ( + <> + // select wallet + {unique.map((connector) => ( + void signIn(connector)} + disabled={busy} + aria-label={`Connect with ${connector.name}`} + > + [{connector.name}] + + ))} + > + )} + setPicking(false)} + disabled={phase === 'verifying'} + aria-label="Cancel wallet connection" + > + // cancel + + + ) : ( + { + setError(null); + setPicking(true); + }} + disabled={disabled} + aria-label="Sign in with wallet" + > + {PHASE_LABEL.idle} + + )} + {error && } + + ); +} + +/** Renders a wallet refusal as a refusal rather than as a raw provider dump. */ +function rejectionOf(failure: unknown): string { + const text = errorMessage(failure); + return /user rejected|ACTION_REJECTED/i.test(text) ? 'the wallet request was rejected' : text; +} diff --git a/apps/web/src/engine/config.test.ts b/apps/web/src/engine/config.test.ts index 003702f71..619ca99d2 100644 --- a/apps/web/src/engine/config.test.ts +++ b/apps/web/src/engine/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { engineHostConfig } from './config'; +import { engineHostConfig, environment } from './config'; const artifact = { wasmModuleUrl: '/assets/cipherbox_wasm-deadbeef.js', @@ -38,4 +38,25 @@ describe('engineHostConfig', () => { expect(config.apiBaseUrl).toBe('http://localhost:3000'); expect(config.recordEndpoints).toEqual(['https://delegated-ipfs.dev']); }); + + it('reads a blank API origin as unconfigured rather than as a base URL', () => { + // `VITE_API_URL=` reads as `''`, which `new URL` rejects outright. + expect(engineHostConfig({ VITE_API_URL: '' }, artifact).apiBaseUrl).toBe( + 'http://localhost:3000' + ); + }); +}); + +describe('environment', () => { + it('names the deployment, defaulting an absent value to local', () => { + expect(environment({ VITE_ENVIRONMENT: 'staging' })).toBe('staging'); + expect(environment({ VITE_ENVIRONMENT: '' })).toBe('local'); + expect(environment({})).toBe('local'); + }); + + it('rejects an unrecognized deployment rather than silently defaulting it', () => { + // A typo would otherwise pick the wrong Web3Auth network, deriving a + // different identity over an empty vault. + expect(() => environment({ VITE_ENVIRONMENT: 'producton' })).toThrow(/VITE_ENVIRONMENT/); + }); }); diff --git a/apps/web/src/engine/config.ts b/apps/web/src/engine/config.ts index 0df30c266..50588aca4 100644 --- a/apps/web/src/engine/config.ts +++ b/apps/web/src/engine/config.ts @@ -3,6 +3,31 @@ import type { EngineHostConfig } from '@cipherbox/client'; const DEFAULT_API_URL = 'http://localhost:3000'; const DEFAULT_ROUTING_ENDPOINTS = 'https://delegated-ipfs.dev'; +/** The deployments the build-time environment names. */ +export type Environment = 'local' | 'ci' | 'staging' | 'production'; + +/** The API origin the engine authenticates and publishes against. */ +export function apiBaseUrl(env: Partial): string { + // `VITE_API_URL=` reads as `''`, which `new URL` rejects rather than defaults. + return env.VITE_API_URL || DEFAULT_API_URL; +} + +const ENVIRONMENTS: readonly Environment[] = ['local', 'ci', 'staging', 'production']; + +/** + * Which deployment this build is; absent means a working-copy `vite dev`. A + * typo is rejected rather than defaulted: it would silently pick the wrong + * Web3Auth network, and so a different identity over an empty vault. + */ +export function environment(env: Partial): Environment { + const value = env.VITE_ENVIRONMENT; + if (value === undefined || value === '') return 'local'; + if (!ENVIRONMENTS.includes(value as Environment)) { + throw new Error(`VITE_ENVIRONMENT must be one of ${ENVIRONMENTS.join(', ')}`); + } + return value as Environment; +} + /** * Reads the app's build-time environment into the engine host's configuration. * The artifact URLs come from the bundler, not the environment. @@ -21,7 +46,7 @@ export function engineHostConfig( } return { - apiBaseUrl: env.VITE_API_URL ?? DEFAULT_API_URL, + apiBaseUrl: apiBaseUrl(env), recordEndpoints, ...artifact, }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 000000000..083141abd --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,156 @@ +/* ========================================================================== + Design Tokens - Terminal/Hacker Aesthetic + ========================================================================== */ + +:root { + /* Color Primitives */ + --color-black: #000000; + --color-green-primary: #00d084; + --color-green-dim: #006644; + --color-green-darker: #003322; + --color-green-glow: #00d08466; /* 40% opacity for shadows */ + --color-error: #ef4444; + --color-warning: #ff6b00; + --color-warning-dim: #994400; + --color-warning-bg: #3d1a00; + + /* Background & Text */ + --color-background: var(--color-black); + --color-border-dim: var(--color-green-darker); + --color-text-primary: var(--color-green-primary); + --color-text-secondary: var(--color-green-dim); + --color-text-dim: #4a5a4e; /* Dim gray-green for hints */ + + /* Typography */ + --font-family-mono: 'JetBrains Mono', monospace; + --font-size-xxs: 9px; /* Footer */ + --font-size-xs: 10px; /* Status text */ + --font-size-sm: 11px; /* Body, buttons */ + --font-size-md: 13px; /* Banner headline */ + --font-size-xl: 24px; /* Login logo */ + + /* Font Weights */ + --font-weight-normal: 400; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + /* Spacing (from design file) */ + --spacing-xs: 8px; + --spacing-sm: 12px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* Effects */ + --glow-green: 0 0 10px var(--color-green-glow); + --border-thickness: 1px; + + /* Remove color-scheme to enforce dark mode only */ + color-scheme: dark; +} + +/* ========================================================================== + Global Resets + ========================================================================== */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ========================================================================== + Base Styles + ========================================================================== */ + +html { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + line-height: 1.5; + font-weight: var(--font-weight-normal); + color: var(--color-text-primary); + background-color: var(--color-background); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + min-height: 100vh; +} + +#root { + width: 100%; + min-height: 100vh; +} + +/* Links */ +a { + color: var(--color-text-primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Focus styles for accessibility */ +:focus-visible { + outline: 2px solid var(--color-green-primary); + outline-offset: 2px; +} + +/* Selection */ +::selection { + background-color: var(--color-green-primary); + color: var(--color-black); +} + +/* ========================================================================== + Utilities + ========================================================================== */ + +.matrix-canvas { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* ========================================================================== + Chrome + ========================================================================== */ + +.logout-link { + background: none; + border: none; + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + text-transform: lowercase; + cursor: pointer; +} + +.logout-link:hover:not(:disabled) { + color: var(--color-text-primary); +} + +.logout-link:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/apps/web/src/lib/errorMessage.ts b/apps/web/src/lib/errorMessage.ts new file mode 100644 index 000000000..4b1ca1b55 --- /dev/null +++ b/apps/web/src/lib/errorMessage.ts @@ -0,0 +1,4 @@ +/** Renders an unknown throw as the one line the UI shows for it. */ +export function errorMessage(failure: unknown): string { + return failure instanceof Error ? failure.message : String(failure); +} diff --git a/apps/web/src/lib/wagmi.ts b/apps/web/src/lib/wagmi.ts new file mode 100644 index 000000000..19e3861d8 --- /dev/null +++ b/apps/web/src/lib/wagmi.ts @@ -0,0 +1,17 @@ +import { createConfig, http } from 'wagmi'; +import { mainnet } from 'wagmi/chains'; +import { injected } from 'wagmi/connectors'; + +/** + * Wallet discovery for SIWE only — CipherBox sends no transactions. The chain + * exists because EIP-4361 messages carry a chainId; mainnet is the canonical + * one. `injected()` picks up every EIP-6963 wallet the browser announces. + */ +export const wagmiConfig = createConfig({ + chains: [mainnet], + connectors: [injected()], + transports: { [mainnet.id]: http() }, + // `null` disables wagmi's persist middleware, which otherwise writes the + // connected `accounts` — the wallet address — to localStorage. + storage: null, +}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index cf96605e5..4b3324700 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,12 +1,18 @@ // First import: Web3Auth's dependency graph reads the globals this installs. import './polyfills'; +import './index.css'; +import './styles/login.css'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter } from 'react-router-dom'; +import { WagmiProvider } from 'wagmi'; import { App } from './App'; +import { createCoreKitSession } from './auth/coreKit'; +import { CoreKitProvider } from './auth/CoreKitProvider'; import { createEngineClient } from './engine/createEngineClient'; +import { wagmiConfig } from './lib/wagmi'; import { EngineProvider } from './providers/EngineProvider'; const rootElement = document.getElementById('root'); @@ -18,12 +24,19 @@ const queryClient = new QueryClient(); createRoot(rootElement).render( - - - - - - - + {/* wagmi drives its own react-query cache, so it wraps the QueryClient. The + wallet is transient — reconnecting one on load would only surface stale + connector errors on a page that needs a signature, not a session. */} + + + + createCoreKitSession(import.meta.env)}> + + + + + + + ); diff --git a/apps/web/src/providers/EngineProvider.tsx b/apps/web/src/providers/EngineProvider.tsx index 1150fdfad..9c948d7d4 100644 --- a/apps/web/src/providers/EngineProvider.tsx +++ b/apps/web/src/providers/EngineProvider.tsx @@ -1,4 +1,12 @@ -import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; import type { EngineClient, SecretSource } from '@cipherbox/client'; import { LoginSecretSource } from '../engine/loginHandoff'; import { @@ -11,6 +19,7 @@ interface EngineContextValue { client: EngineClient; snapshots: SnapshotStore; secrets: LoginSecretSource; + rebuild: () => void; } // `undefined` distinguishes "no provider above me" from "provider mounted, @@ -26,20 +35,24 @@ export interface EngineProviderProps { /** * Owns everything scoped to this tab's one engine (blueprint/web-client.md * "Engine hosting and tab leadership"): the client, the snapshot store over it, - * and the failover secret source — built on mount, torn down together on - * unmount, never duplicated. Construction runs in an effect so a StrictMode - * double-mount disposes the throwaway client rather than leaking a second lock - * contender. + * and the failover secret source — built together, torn down together, never + * duplicated. Construction runs in an effect so a StrictMode double-mount + * disposes the throwaway client rather than leaking a second lock contender. */ export function EngineProvider({ createClient, children }: EngineProviderProps) { const [value, setValue] = useState(null); + const [generation, setGeneration] = useState(0); const factory = useRef(createClient); + // `facade.logout` closes the client for good, so the tab needs a new one + // before it can log in again. + const rebuild = useCallback(() => setGeneration((current) => current + 1), []); + useEffect(() => { const secrets = new LoginSecretSource(); const client = factory.current(secrets); const snapshots = createSnapshotStore(client); - setValue({ client, snapshots, secrets }); + setValue({ client, snapshots, secrets, rebuild }); return () => { // Drop the exporter first: no re-export capability outlives the client. secrets.use(null); @@ -48,7 +61,7 @@ export function EngineProvider({ createClient, children }: EngineProviderProps) console.error('[engine] dispose failed', error instanceof Error ? error.message : error); }); }; - }, []); + }, [generation, rebuild]); return {children}; } @@ -75,3 +88,11 @@ export function useSnapshotStore(): SnapshotStore { export function useLoginSecretSource(): LoginSecretSource | null { return useEngineContext()?.secrets ?? null; } + +/** Replaces this tab's engine client with a fresh one; a no-op before the first. */ +export function useRebuildEngine(): () => void { + const rebuild = useEngineContext()?.rebuild; + return rebuild ?? noop; +} + +const noop = () => undefined; diff --git a/apps/web/src/routes/FilesPage.tsx b/apps/web/src/routes/FilesPage.tsx index 9d6c0c66a..59c0d43c8 100644 --- a/apps/web/src/routes/FilesPage.tsx +++ b/apps/web/src/routes/FilesPage.tsx @@ -1,4 +1,5 @@ import { useParams } from 'react-router-dom'; +import { LogoutButton } from '../components/auth/LogoutButton'; /** Placeholder for the vault browser (#805). */ export function FilesPage() { @@ -9,6 +10,7 @@ export function FilesPage() { Files {nodeId ?? 'root'} + ); } diff --git a/apps/web/src/routes/LoginPage.tsx b/apps/web/src/routes/LoginPage.tsx index 42fb870fa..71bb507e5 100644 --- a/apps/web/src/routes/LoginPage.tsx +++ b/apps/web/src/routes/LoginPage.tsx @@ -1,9 +1,94 @@ -/** Placeholder for the harvested login UI (#804). */ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth/useAuth'; +import { EmailLoginForm } from '../components/auth/EmailLoginForm'; +import { GoogleLoginButton } from '../components/auth/GoogleLoginButton'; +import { LoginError } from '../components/auth/LoginError'; +import { WalletLoginButton } from '../components/auth/WalletLoginButton'; +import { MatrixBackground } from '../components/MatrixBackground'; +import { StagingBanner } from '../components/StagingBanner'; +import { apiBaseUrl } from '../engine/config'; + +/** + * The vault's front door: the Core Kit methods plus SIWE + * (blueprint/web-client.md "Composition"). + */ export function LoginPage() { + const { + isAuthenticated, + isReady, + isBusy, + error, + loginWithGoogle, + loginWithEmail, + loginWithWallet, + } = useAuth(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + + // Only redirect away from the login route itself, so a late settle cannot yank + // a user who has already navigated on. + useEffect(() => { + if (isAuthenticated && pathname === '/') navigate('/files'); + }, [isAuthenticated, navigate, pathname]); + + // `useAuth` already surfaces the failure as `error`. + const dispatch = (login: Promise) => void login.catch(() => undefined); + return ( - - CipherBox - Sign in - + <> + + + + + CipherBox + zero-knowledge encrypted storage + + your files, encrypted on your device. we never see your data. + + + + dispatch(loginWithGoogle())} + disabled={!isReady} + busy={isBusy} + /> + + + // or + + + dispatch(loginWithEmail(email))} + disabled={!isReady} + busy={isBusy} + /> + + + // or + + + + + + {error && } + + + + > ); } diff --git a/apps/web/src/styles/login.css b/apps/web/src/styles/login.css new file mode 100644 index 000000000..5fc44a9a6 --- /dev/null +++ b/apps/web/src/styles/login.css @@ -0,0 +1,362 @@ +/* ========================================================================== + Login Page - Terminal Aesthetic + ========================================================================== */ + +.login-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: var(--spacing-lg); + text-align: center; + position: relative; + overflow: hidden; +} + +.login-panel { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--spacing-xl) 48px; + background-color: rgb(0 0 0 / 60%); + border: 1px solid rgb(0 208 132 / 10%); +} + +.login-container h1 { + font-family: var(--font-family-mono); + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin-bottom: var(--spacing-xs); + letter-spacing: 0.05em; +} + +.login-container h1::before { + content: '> '; + color: var(--color-text-primary); +} + +.login-container .tagline { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin-bottom: var(--spacing-md); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.login-container .login-description { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin-bottom: var(--spacing-lg); + max-width: 400px; +} + +/* ========================================================================== + Auth methods + ========================================================================== */ + +.login-methods { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--spacing-md); + width: 100%; + max-width: 320px; +} + +.login-divider { + display: flex; + align-items: center; + gap: var(--spacing-sm); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + text-transform: lowercase; + letter-spacing: 0.1em; +} + +.login-divider::before, +.login-divider::after { + content: ''; + flex: 1; + height: 1px; + background-color: var(--color-border-dim); +} + +/* One button shape for every login method; `--filled` inverts it for the + form's own submit, `--loading` dims it while a flow is in flight. */ +.terminal-btn { + padding: var(--spacing-sm) var(--spacing-lg); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + background-color: transparent; + color: var(--color-text-primary); + border: 1px solid var(--color-green-primary); + border-radius: 0; + cursor: pointer; + transition: + box-shadow 0.2s ease, + background-color 0.2s ease, + transform 0.1s ease; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.terminal-btn:hover:not(:disabled) { + background-color: rgb(0 208 132 / 10%); + box-shadow: var(--glow-green); + transform: translateY(-1px); +} + +.terminal-btn:active:not(:disabled) { + transform: translateY(0); +} + +.terminal-btn:focus-visible { + outline: 1px solid var(--color-green-primary); + outline-offset: 2px; +} + +.terminal-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.terminal-btn--loading { + color: var(--color-text-secondary); + border-color: var(--color-text-secondary); +} + +.terminal-btn--filled { + padding: var(--spacing-xs) var(--spacing-lg); + background-color: var(--color-green-primary); + color: var(--color-black); + border-color: var(--color-green-primary); +} + +.terminal-btn--filled:hover:not(:disabled) { + background-color: var(--color-green-primary); +} + +.terminal-btn--filled.terminal-btn--loading { + background-color: var(--color-green-dim); + color: var(--color-text-primary); +} + +/* ========================================================================== + Email Login Form + ========================================================================== */ + +.email-login-form { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--spacing-xs); +} + +.email-login-input { + padding: var(--spacing-xs) var(--spacing-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + background-color: transparent; + color: var(--color-text-primary); + border: 1px solid var(--color-border-dim); + border-radius: 0; + outline: none; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.email-login-input::placeholder { + color: var(--color-text-dim); +} + +.email-login-input:focus, +.email-login-input:focus-visible { + outline: none; + border-color: var(--color-green-primary); + box-shadow: var(--glow-green); +} + +.email-login-input:disabled { + opacity: 0.4; +} + +/* ========================================================================== + Wallet Login + ========================================================================== */ + +.wallet-login-wrapper { + display: flex; + flex-direction: column; + align-items: stretch; +} + +.wallet-connector-list { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + padding: var(--spacing-sm); + border: 1px solid var(--color-border-dim); + font-family: var(--font-family-mono); +} + +.wallet-connector-header { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + margin-bottom: var(--spacing-xs); +} + +.wallet-connector-option { + padding: var(--spacing-xs) var(--spacing-sm); + background: none; + border: 1px solid var(--color-border-dim); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + cursor: pointer; + text-align: left; + transition: + border-color 0.2s ease, + color 0.2s ease; +} + +.wallet-connector-option:hover:not(:disabled) { + border-color: var(--color-green-primary); + color: var(--color-green-primary); +} + +.wallet-connector-option:focus-visible { + outline: 1px solid var(--color-green-primary); + outline-offset: 1px; +} + +.wallet-connector-option:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.wallet-connector-cancel { + padding: var(--spacing-xs) 0; + background: none; + border: none; + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-text-dim); + cursor: pointer; + text-align: center; + margin-top: var(--spacing-xs); +} + +.wallet-connector-cancel:hover:not(:disabled) { + color: var(--color-text-secondary); +} + +.wallet-connector-cancel:focus-visible { + outline: 1px solid var(--color-green-primary); + outline-offset: 1px; +} + +.wallet-connector-cancel:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.wallet-login-status { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + padding: var(--spacing-xs) 0; +} + +.wallet-no-providers { + font-size: var(--font-size-xs); + color: var(--color-text-dim); + padding: var(--spacing-xs) 0; +} + +/* ========================================================================== + Error banner and footer + ========================================================================== */ + +.login-error { + margin-top: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-error); + background-color: rgb(239 68 68 / 8%); + border: 1px solid rgb(239 68 68 / 20%); + text-align: center; +} + +.login-footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-xs) var(--spacing-lg); + background-color: rgb(0 0 0 / 50%); + border-top: 1px solid var(--color-border-dim); + z-index: 1; +} + +.login-footer .footer-copyright { + font-size: var(--font-size-xxs); + color: var(--color-border-dim); +} + +.login-footer .footer-link { + font-size: var(--font-size-xxs); + color: var(--color-text-secondary); + transition: color 0.15s ease; +} + +.login-footer .footer-link:hover { + color: var(--color-text-primary); + text-decoration: none; +} + +.login-footer .footer-link:focus-visible { + outline: 1px solid var(--color-green-primary); + outline-offset: 1px; +} + +/* ========================================================================== + Staging Banner + ========================================================================== */ + +.staging-banner { + position: fixed; + top: 0; + left: 0; + width: 100%; + z-index: 1000; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: var(--spacing-xs) var(--spacing-md); + background-color: var(--color-warning-bg); + border-bottom: 1px solid var(--color-warning); +} + +.staging-banner-title { + font-size: var(--font-size-md); + font-weight: var(--font-weight-bold); + color: var(--color-warning); + letter-spacing: 2px; + text-transform: uppercase; +} + +.staging-banner-detail { + font-size: var(--font-size-sm); + color: var(--color-warning-dim); + text-align: center; +} diff --git a/apps/web/src/test/authFakes.tsx b/apps/web/src/test/authFakes.tsx new file mode 100644 index 000000000..05db04214 --- /dev/null +++ b/apps/web/src/test/authFakes.tsx @@ -0,0 +1,116 @@ +/** + * The engine and Core Kit as the login flow sees them: both seams recorded, so a + * test asserts what the flow dispatched rather than how it got there. + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { EngineClient } from '@cipherbox/client'; +import type { ReactNode } from 'react'; +import { WagmiProvider } from 'wagmi'; +import { CoreKitProvider } from '../auth/CoreKitProvider'; +import type { CoreKitLoginMethod, CoreKitSession } from '../auth/coreKit'; +import { wagmiConfig } from '../lib/wagmi'; +import { EngineProvider } from '../providers/EngineProvider'; + +/** A 32-byte scalar in the hex shape Core Kit exports. */ +export const SECRET_HEX = '0f'.repeat(32); + +export interface EngineCalls { + /** The buffers `start` was handed, still live so a test can check zeroization. */ + started: ArrayBuffer[]; + /** What each buffer held on arrival, before the handoff scrubbed it. */ + secrets: Uint8Array[]; + siwe: { message: string; signature: Uint8Array }[]; + logouts: number; +} + +export function fakeEngineClient( + overrides: Partial Promise>> = {} +) { + const calls: EngineCalls = { started: [], secrets: [], logouts: 0, siwe: [] }; + const client = { + facade: { + start(secret: ArrayBuffer) { + calls.started.push(secret); + calls.secrets.push(new Uint8Array(secret).slice()); + return overrides.start?.() ?? Promise.resolve(); + }, + siweLogin(message: string, signature: Uint8Array) { + calls.siwe.push({ message, signature }); + return Promise.resolve(); + }, + logout() { + calls.logouts += 1; + return overrides.logout?.() ?? Promise.resolve(); + }, + subscribe: () => () => undefined, + snapshot: () => new Promise(() => undefined), + setFocus: () => Promise.resolve(), + }, + reportFocus: () => undefined, + dispose: () => Promise.resolve(), + } as unknown as EngineClient; + return { client, calls }; +} + +export interface CoreKitCalls { + logins: { method: CoreKitLoginMethod; email?: string }[]; + exports: number; + logouts: number; +} + +export function fakeCoreKitSession( + options: { loggedIn?: boolean; email?: () => string | null } = {} +) { + const calls: CoreKitCalls = { logins: [], exports: 0, logouts: 0 }; + let loggedIn = options.loggedIn ?? false; + const session: CoreKitSession = { + restore: () => Promise.resolve(), + isLoggedIn: () => loggedIn, + login(method, email) { + calls.logins.push({ method, email }); + loggedIn = true; + return Promise.resolve(); + }, + method: () => 'google', + email: options.email ?? (() => 'user@example.test'), + logout() { + calls.logouts += 1; + loggedIn = false; + return Promise.resolve(); + }, + _UNSAFE_exportTssKey() { + calls.exports += 1; + return Promise.resolve(SECRET_HEX); + }, + }; + return { session, calls }; +} + +/** Mounts the two providers the login flow reads, over the given fakes. */ +export function authWrapper(client: EngineClient, session: CoreKitSession) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + client}> + session}>{children} + + ); + }; +} + +/** `authWrapper` plus the wallet-side providers the login *page* also mounts. */ +export function pageWrapper(client: EngineClient, session: CoreKitSession) { + const Auth = authWrapper(client, session); + // One client per wrapper, not per render: wagmi's cache must survive a + // re-render or the wallet flow reads as a fresh, disconnected mount. + const queries = new QueryClient(); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + }; +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index c555fcfa4..7fa17b00d 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -5,4 +5,9 @@ interface ImportMetaEnv { readonly VITE_API_URL?: string; /** Comma-separated `/routing/v1` origins: someguy plus a public endpoint. */ readonly VITE_ROUTING_ENDPOINTS?: string; + /** `local` | `ci` | `staging` | `production` — picks the Web3Auth network. */ + readonly VITE_ENVIRONMENT?: string; + readonly VITE_WEB3AUTH_CLIENT_ID?: string; + /** The Web3Auth verifier the Core Kit login flows authenticate against. */ + readonly VITE_WEB3AUTH_VERIFIER?: string; }
{nodeId ?? 'root'}
Sign in
zero-knowledge encrypted storage
+ your files, encrypted on your device. we never see your data. +