diff --git a/apps/web/src/auth/useAuth.test.tsx b/apps/web/src/auth/useAuth.test.tsx index c9b9fb92b..3b1818922 100644 --- a/apps/web/src/auth/useAuth.test.tsx +++ b/apps/web/src/auth/useAuth.test.tsx @@ -1,3 +1,4 @@ +import { EngineHeldElsewhereError } from '@cipherbox/client'; import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; import { authStore } from '../stores/auth.store'; @@ -341,3 +342,60 @@ describe('useAuth', () => { expect(rendered).not.toContain([...SECRET_BYTES].join(',')); }); }); + +describe('useAuth against an engine another account holds', () => { + beforeEach(() => authStore.signedOut()); + + it('renders the refusal as a signed-in-elsewhere state naming the holder', async () => { + const engine = fakeEngineClient({ + start: () => Promise.reject(new EngineHeldElsewhereError('other-account')), + }); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(async () => { + await expect(result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)).rejects.toThrow(); + }); + + expect(result.current.auth.heldElsewhere).toEqual({ heldBy: 'other-account' }); + // A one-line banner cannot say what to do about it, so none is rendered. + expect(result.current.auth.error).toBeNull(); + expect(authStore.getState().isAuthenticated).toBe(false); + // The credential the engine refused does not outlive the refusal. + expect(coreKit.calls.logouts).toBe(1); + }); + + it('reports a tab hosting the engine that has started none as holding no account', async () => { + const engine = fakeEngineClient({ + start: () => Promise.reject(new EngineHeldElsewhereError(null)), + }); + const { result } = mount(engine, fakeCoreKitSession()); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(async () => { + await expect(result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)).rejects.toThrow(); + }); + + expect(result.current.auth.heldElsewhere).toEqual({ heldBy: null }); + }); + + it('clears the state when the next attempt begins', async () => { + let refuse = true; + const engine = fakeEngineClient({ + start: () => + refuse ? Promise.reject(new EngineHeldElsewhereError(null)) : Promise.resolve(), + }); + const { result } = mount(engine, fakeCoreKitSession()); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + await act(async () => { + await expect(result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)).rejects.toThrow(); + }); + + refuse = false; + await act(() => result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)); + + expect(result.current.auth.heldElsewhere).toBeNull(); + expect(authStore.getState()).toMatchObject({ isAuthenticated: true }); + }); +}); diff --git a/apps/web/src/auth/useAuth.ts b/apps/web/src/auth/useAuth.ts index 141c92037..01fe1dbb9 100644 --- a/apps/web/src/auth/useAuth.ts +++ b/apps/web/src/auth/useAuth.ts @@ -6,6 +6,7 @@ */ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { EngineHeldElsewhereError } from '@cipherbox/client'; import { createLoginFlow, RecoveryRequiredError, type LoginProgress } from '@cipherbox/login'; import { errorMessage } from '../lib/errorMessage'; import { authStore, useAuthState } from '../stores/auth.store'; @@ -15,6 +16,11 @@ import { useCoreKit } from './CoreKitProvider'; import { useIdentity } from './IdentityProvider'; import type { WebCollected } from './webCollector'; +/** The origin's engine belongs to another account; `heldBy` names it. */ +export interface HeldElsewhere { + heldBy: string | null; +} + export interface Auth { isAuthenticated: boolean; /** True while the tab is still assembling its engine or Core Kit session. */ @@ -28,6 +34,8 @@ export interface Auth { isBusy: boolean; /** The last failure, already stripped of anything secret-shaped. */ error: string | null; + /** Set when this tab was refused rather than served another account's vault. */ + heldElsewhere: HeldElsewhere | null; /** Exchanges a Google ID token collected on this host. */ loginWithGoogle(idToken: string): Promise; /** Asks CipherBox to deliver a verification code. */ @@ -60,6 +68,7 @@ export function useAuth(): Auth { const [isBusy, setIsBusy] = useState(false); const [error, setError] = useState(null); + const [heldElsewhere, setHeldElsewhere] = useState(null); const isReady = client !== null && session !== null && status === 'ready'; const isSignedOut = !isAuthenticated && (isReady || status === 'unavailable'); @@ -69,8 +78,17 @@ export function useAuth(): Auth { begin: () => { setIsBusy(true); setError(null); + setHeldElsewhere(null); + }, + // A refusal by account is a state the front door renders in full, not a + // one-line failure: its message alone cannot say what to do about it. + failed: (failure) => { + if (failure instanceof EngineHeldElsewhereError) { + setHeldElsewhere({ heldBy: failure.heldBy }); + return; + } + setError(errorMessage(failure)); }, - failed: (failure) => setError(errorMessage(failure)), end: () => setIsBusy(false), }), [] @@ -166,6 +184,7 @@ export function useAuth(): Auth { isSignedOut, isBusy, error: error ?? coreKitError, + heldElsewhere, loginWithGoogle, sendEmailCode: flow.sendEmailCode, loginWithEmailCode, diff --git a/apps/web/src/components/auth/LoginError.tsx b/apps/web/src/components/auth/LoginError.tsx index 99b0db35b..a5c3936cf 100644 --- a/apps/web/src/components/auth/LoginError.tsx +++ b/apps/web/src/components/auth/LoginError.tsx @@ -1,5 +1,7 @@ +import type { ReactNode } from 'react'; + /** The one error banner the login page and its methods both render. */ -export function LoginError({ message }: { message: string }) { +export function LoginError({ message }: { message: ReactNode }) { return (
{message} diff --git a/apps/web/src/components/auth/SignedInElsewhere.tsx b/apps/web/src/components/auth/SignedInElsewhere.tsx new file mode 100644 index 000000000..d81ffcedf --- /dev/null +++ b/apps/web/src/components/auth/SignedInElsewhere.tsx @@ -0,0 +1,24 @@ +import { LoginError } from './LoginError'; +import { shortAccountId } from '../../utils/format'; + +/** + * What a sign-in refused by the origin's one engine shows (`PortRequest`). Only + * the tab holding that engine can give it up, so the way out is stated here + * rather than offered as a button that cannot reach it. + */ +export function SignedInElsewhere({ heldBy }: { heldBy: string | null }) { + return ( + +

+ {heldBy === null + ? 'another tab in this browser is running CipherBox and is not signed in.' + : `another account is already signed in to CipherBox in this browser: ${shortAccountId(heldBy)}.`} +

+

sign out in that tab, or close it, then sign in again here.

+ + } + /> + ); +} diff --git a/apps/web/src/routes/LoginPage.tsx b/apps/web/src/routes/LoginPage.tsx index 011a2b7eb..8937526bf 100644 --- a/apps/web/src/routes/LoginPage.tsx +++ b/apps/web/src/routes/LoginPage.tsx @@ -6,6 +6,7 @@ import { EmailLoginForm } from '../components/auth/EmailLoginForm'; import { GoogleLoginButton } from '../components/auth/GoogleLoginButton'; import { LoginError } from '../components/auth/LoginError'; import { RecoveryPhraseLogin } from '../components/auth/RecoveryPhraseLogin'; +import { SignedInElsewhere } from '../components/auth/SignedInElsewhere'; import { WalletLoginButton } from '../components/auth/WalletLoginButton'; import { MatrixBackground } from '../components/MatrixBackground'; import { StagingBanner } from '../components/StagingBanner'; @@ -20,6 +21,7 @@ export function LoginPage() { isReady, isBusy, error, + heldElsewhere, loginWithGoogle, sendEmailCode, loginWithEmailCode, @@ -86,6 +88,7 @@ export function LoginPage() {
)} + {heldElsewhere && } {error && !recoveryRequired && }