From e5a8741eaaa1914d843ea84e1adbff8090b3e9db Mon Sep 17 00:00:00 2001 From: jzunigax2 <125698953+jzunigax2@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:12:22 -0300 Subject: [PATCH] feat: implement mail account setup functionality and enhance access control - Introduced `useSetupMailAccount` hook to manage mail account provisioning, including error handling for unsupported plans. - Updated `ProtectedRoute` and `PublicRoute` components to redirect users based on mail access status. - Added `useMailAccess` hook to determine user access to mail features based on their subscription tier. - Enhanced error notifications and user feedback for mail setup failures. - Added tests for new hooks and route components to ensure proper functionality and coverage. --- src/errors/index.ts | 1 + src/errors/payments/index.ts | 11 ++ .../hooks/useSetupMailAccount.test.tsx | 137 ++++++++++++++++ .../hooks/useSetupMailAccount.ts | 83 ++++++++++ src/features/identity-setup/index.tsx | 52 +----- src/features/welcome/index.tsx | 35 +++- src/hooks/mail/useMailAccess.test.tsx | 151 ++++++++++++++++++ src/hooks/mail/useMailAccess.ts | 36 +++++ src/hooks/mail/useMailAccountGuard.test.tsx | 26 ++- src/hooks/mail/useMailAccountGuard.ts | 4 +- src/i18n/locales/en.json | 7 +- src/i18n/locales/es.json | 7 +- src/i18n/locales/fr.json | 7 +- src/i18n/locales/it.json | 7 +- src/routes/guards/ProtectedRoute.test.tsx | 88 ++++++++++ src/routes/guards/ProtectedRoute.tsx | 15 +- src/routes/guards/PublicRoute.test.tsx | 66 ++++++++ src/routes/guards/PublicRoute.tsx | 18 ++- src/store/api/base.ts | 1 + src/store/api/payments/index.ts | 24 +++ 20 files changed, 705 insertions(+), 71 deletions(-) create mode 100644 src/errors/payments/index.ts create mode 100644 src/features/identity-setup/hooks/useSetupMailAccount.test.tsx create mode 100644 src/features/identity-setup/hooks/useSetupMailAccount.ts create mode 100644 src/hooks/mail/useMailAccess.test.tsx create mode 100644 src/hooks/mail/useMailAccess.ts create mode 100644 src/routes/guards/ProtectedRoute.test.tsx create mode 100644 src/routes/guards/PublicRoute.test.tsx create mode 100644 src/store/api/payments/index.ts diff --git a/src/errors/index.ts b/src/errors/index.ts index 421f528..6fbdd02 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -1,6 +1,7 @@ export * from './config'; export * from './navigation'; export * from './oauth'; +export * from './payments'; export * from './storage'; export * from './shared'; export * from './mail'; diff --git a/src/errors/payments/index.ts b/src/errors/payments/index.ts new file mode 100644 index 0000000..aa985d4 --- /dev/null +++ b/src/errors/payments/index.ts @@ -0,0 +1,11 @@ +export class FetchUserTierError extends Error { + constructor( + message: string, + public requestId?: string, + ) { + super(message); + this.requestId = requestId; + + Object.setPrototypeOf(this, FetchUserTierError.prototype); + } +} diff --git a/src/features/identity-setup/hooks/useSetupMailAccount.test.tsx b/src/features/identity-setup/hooks/useSetupMailAccount.test.tsx new file mode 100644 index 0000000..8c5e3f9 --- /dev/null +++ b/src/features/identity-setup/hooks/useSetupMailAccount.test.tsx @@ -0,0 +1,137 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { Provider } from 'react-redux'; +import type { PropsWithChildren } from 'react'; +import { createEncryptionAndRecoveryKeystores } from 'internxt-crypto'; +import { useSetupMailAccount } from './useSetupMailAccount'; +import { createTestStore } from '@/test-utils/createTestStore'; +import { CryptoService } from '@/services/crypto'; +import { ErrorService } from '@/services/error'; +import { LocalStorageService } from '@/services/local-storage'; +import { NavigationService } from '@/services/navigation'; +import { MailService } from '@/services/sdk/mail'; +import { AppView } from '@/routes/paths'; +import { ToastType } from '@/services/notifications'; + +vi.mock('internxt-crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createEncryptionAndRecoveryKeystores: vi.fn(), + }; +}); + +vi.mock('@/i18n', () => ({ + useTranslationContext: () => ({ translate: (key: string) => key }), +})); + +const mockedCreateKeystores = vi.mocked(createEncryptionAndRecoveryKeystores); + +const createWrapper = (store: ReturnType) => { + return ({ children }: PropsWithChildren) => {children}; +}; + +const newAddress = { address: 'jane', domain: 'inxt.me', hashedPassword: 'hashed-password' }; + +const renderSetupHook = () => { + const store = createTestStore({ user: { isAuthenticated: true } }); + return renderHook(() => useSetupMailAccount({ userFullName: 'Jane Doe' }), { wrapper: createWrapper(store) }); +}; + +describe('useSetupMailAccount', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockedCreateKeystores.mockReset(); + mockedCreateKeystores.mockResolvedValue({ + encryptionKeystore: { publicKey: 'pub', privateKeyEncrypted: 'enc' }, + recoveryKeystore: { privateKeyEncrypted: 'rec' }, + } as never); + vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic'); + vi.spyOn(CryptoService.instance, 'encryptTextWithKey').mockReturnValue('encrypted-password'); + }); + + test('When provisioning succeeds, then it should take the user to the inbox', async () => { + vi.spyOn(MailService.instance, 'setupMailAccount').mockResolvedValue({ address: 'jane@inxt.me' }); + const replaceSpy = vi.spyOn(NavigationService.instance, 'replace').mockImplementation(() => undefined as never); + + const { result } = renderSetupHook(); + await act(() => result.current.setupMailAccount(newAddress)); + + expect(MailService.instance.setupMailAccount).toHaveBeenCalledWith( + expect.objectContaining({ address: 'jane', domain: 'inxt.me', displayName: 'Jane Doe' }), + ); + expect(replaceSpy).toHaveBeenCalledWith({ id: AppView.Inbox }); + }); + + test('When provisioning is rejected because of the plan, then it should say so and take the user to the upgrade screen', async () => { + vi.spyOn(MailService.instance, 'setupMailAccount').mockRejectedValue(new Error('Forbidden')); + vi.spyOn(ErrorService.instance, 'castError').mockReturnValue({ + message: 'Mail access is not available for your current plan', + status: 403, + } as never); + const notifySpy = vi.spyOn(ErrorService.instance, 'notifyUser').mockImplementation(() => undefined); + const replaceSpy = vi.spyOn(NavigationService.instance, 'replace').mockImplementation(() => undefined as never); + + const { result } = renderSetupHook(); + await act(() => result.current.setupMailAccount(newAddress)); + + expect(notifySpy).toHaveBeenCalledWith('errors.identitySetup.planNotSupported', ToastType.Warning); + expect(replaceSpy).toHaveBeenCalledWith({ + id: AppView.Welcome, + options: { state: { planAlreadyNotified: true } }, + }); + }); + + test('When provisioning fails for another reason, then it should show the generic error and stay in place', async () => { + vi.spyOn(MailService.instance, 'setupMailAccount').mockRejectedValue(new Error('Network error')); + vi.spyOn(ErrorService.instance, 'castError').mockReturnValue({ + message: 'Network error', + status: 500, + } as never); + const notifySpy = vi.spyOn(ErrorService.instance, 'notifyUser').mockImplementation(() => undefined); + const replaceSpy = vi.spyOn(NavigationService.instance, 'replace').mockImplementation(() => undefined as never); + + const { result } = renderSetupHook(); + await act(() => result.current.setupMailAccount(newAddress)); + + expect(notifySpy).toHaveBeenCalledWith('errors.identitySetup.setupFailed'); + expect(replaceSpy).not.toHaveBeenCalled(); + }); + + test('When the mnemonic is missing, then it should not attempt to provision the account', async () => { + vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue(null); + const setupSpy = vi.spyOn(MailService.instance, 'setupMailAccount'); + const notifySpy = vi.spyOn(ErrorService.instance, 'notifyUser').mockImplementation(() => undefined); + + const { result } = renderSetupHook(); + await act(() => result.current.setupMailAccount(newAddress)); + + expect(setupSpy).not.toHaveBeenCalled(); + expect(notifySpy).toHaveBeenCalledWith('errors.identitySetup.setupFailed'); + }); + + test('When provisioning is in flight, then it should report progress and clear it once settled', async () => { + let resolveSetup: (value: { address: string }) => void = () => undefined; + vi.spyOn(MailService.instance, 'setupMailAccount').mockReturnValue( + new Promise((resolve) => { + resolveSetup = resolve; + }), + ); + vi.spyOn(NavigationService.instance, 'replace').mockImplementation(() => undefined as never); + + const { result } = renderSetupHook(); + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.setupMailAccount(newAddress); + }); + + await waitFor(() => expect(result.current.isConfirmingChange).toBe(true)); + + await act(async () => { + resolveSetup({ address: 'jane@inxt.me' }); + await pending; + }); + + expect(result.current.isConfirmingChange).toBe(false); + }); +}); diff --git a/src/features/identity-setup/hooks/useSetupMailAccount.ts b/src/features/identity-setup/hooks/useSetupMailAccount.ts new file mode 100644 index 0000000..b23aba4 --- /dev/null +++ b/src/features/identity-setup/hooks/useSetupMailAccount.ts @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { createEncryptionAndRecoveryKeystores } from 'internxt-crypto'; +import type { SetupMailAccountPayload } from '@internxt/sdk/dist/mail/types'; +import { useTranslationContext } from '@/i18n'; +import { AppView } from '@/routes/paths'; +import { CryptoService } from '@/services/crypto'; +import { ErrorService } from '@/services/error'; +import { LocalStorageService } from '@/services/local-storage'; +import { NavigationService } from '@/services/navigation'; +import { ToastType } from '@/services/notifications'; +import { MailService } from '@/services/sdk/mail'; +import { mailApi } from '@/store/api/mail'; +import { paymentsApi } from '@/store/api/payments'; +import { useAppDispatch } from '@/store/hooks'; + +interface UseSetupMailAccountParams { + userFullName: string; +} + +interface SetupMailAccountParams { + address: string; + domain: string; + hashedPassword: string; +} + +export const useSetupMailAccount = ({ userFullName }: UseSetupMailAccountParams) => { + const { translate } = useTranslationContext(); + const dispatch = useAppDispatch(); + const [isConfirmingChange, setIsConfirmingChange] = useState(false); + + const setupMailAccount = async ({ address, domain, hashedPassword }: SetupMailAccountParams) => { + setIsConfirmingChange(true); + + try { + const mailboxEmail = `${address}@${domain}`; + const mnemonic = LocalStorageService.instance.getMnemonic(); + + if (!mnemonic) { + ErrorService.instance.notifyUser(translate('errors.identitySetup.setupFailed')); + return; + } + + const { encryptionKeystore, recoveryKeystore } = await createEncryptionAndRecoveryKeystores( + mailboxEmail, + mnemonic, + ); + + const confirmIdentitySetupPayload: SetupMailAccountPayload = { + address, + displayName: userFullName, + domain, + password: CryptoService.instance.encryptTextWithKey(hashedPassword), + keys: { + publicKey: encryptionKeystore.publicKey, + encryptionPrivateKey: encryptionKeystore.privateKeyEncrypted, + recoveryPrivateKey: recoveryKeystore.privateKeyEncrypted, + }, + }; + + await MailService.instance.setupMailAccount(confirmIdentitySetupPayload); + dispatch(mailApi.util.invalidateTags(['MailAccountKeys'])); + NavigationService.instance.replace({ id: AppView.Inbox }); + } catch (error) { + const err = ErrorService.instance.castError(error); + + if (err.status === 403) { + ErrorService.instance.notifyUser(translate('errors.identitySetup.planNotSupported'), ToastType.Warning); + dispatch(paymentsApi.util.invalidateTags(['UserTier'])); + NavigationService.instance.replace({ + id: AppView.Welcome, + options: { state: { planAlreadyNotified: true } }, + }); + return; + } + + ErrorService.instance.notifyUser(translate('errors.identitySetup.setupFailed')); + } finally { + setIsConfirmingChange(false); + } + }; + + return { isConfirmingChange, setupMailAccount }; +}; diff --git a/src/features/identity-setup/index.tsx b/src/features/identity-setup/index.tsx index 41565da..9eba36f 100644 --- a/src/features/identity-setup/index.tsx +++ b/src/features/identity-setup/index.tsx @@ -1,22 +1,16 @@ import { DEFAULT_USER_NAME } from '@/constants'; import { useTranslationContext } from '@/i18n'; -import { AppView } from '@/routes/paths'; -import { CryptoService } from '@/services/crypto'; import { ErrorService } from '@/services/error'; -import { LocalStorageService } from '@/services/local-storage'; -import { NavigationService } from '@/services/navigation'; import { AuthService } from '@/services/sdk/auth'; import { MailService } from '@/services/sdk/mail'; -import { mailApi } from '@/store/api/mail'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import type { SetupMailAccountPayload } from '@internxt/sdk/dist/mail/types'; -import { createEncryptionAndRecoveryKeystores } from 'internxt-crypto'; +import { useAppSelector } from '@/store/hooks'; import { use, useState, type ReactNode } from 'react'; import { ConfirmChange } from './components/ConfirmChange'; import { ConfirmPassword } from './components/ConfirmPassword'; import { Footer } from './components/Footer'; import { Header } from './components/Header'; import { UpdateEmail } from './components/UpdateEmail'; +import { useSetupMailAccount } from './hooks/useSetupMailAccount'; type Step = 'updateEmail' | 'confirmPassword' | 'confirmChange'; @@ -28,13 +22,12 @@ const IdentitySetup = () => { address: '', domain: '', }); - const [isConfirmingChange, setIsConfirmingChange] = useState(false); const [hashedPassword, setHashedPassword] = useState(''); const [step, setStep] = useState('updateEmail'); - const dispatch = useAppDispatch(); const { user } = useAppSelector((state) => state.user); const currentEmail = user?.email ?? ''; const userFullName = user ? `${user.name} ${user.lastname}` : DEFAULT_USER_NAME; + const { isConfirmingChange, setupMailAccount } = useSetupMailAccount({ userFullName }); const onConfirmPassword = async (password: string) => { try { @@ -57,44 +50,7 @@ const IdentitySetup = () => { } }; - const onConfirmChange = async () => { - setIsConfirmingChange(true); - - try { - const mailboxEmail = `${newEmail.address}@${newEmail.domain}`; - const mnemonic = LocalStorageService.instance.getMnemonic(); - - if (!mnemonic) { - ErrorService.instance.notifyUser(translate('errors.identitySetup.setupFailed')); - return; - } - - const { encryptionKeystore, recoveryKeystore } = await createEncryptionAndRecoveryKeystores( - mailboxEmail, - mnemonic, - ); - - const confirmIdentitySetupPayload: SetupMailAccountPayload = { - address: newEmail.address, - displayName: userFullName, - domain: newEmail.domain, - password: CryptoService.instance.encryptTextWithKey(hashedPassword), - keys: { - publicKey: encryptionKeystore.publicKey, - encryptionPrivateKey: encryptionKeystore.privateKeyEncrypted, - recoveryPrivateKey: recoveryKeystore.privateKeyEncrypted, - }, - }; - - await MailService.instance.setupMailAccount(confirmIdentitySetupPayload); - dispatch(mailApi.util.invalidateTags(['MailAccountKeys'])); - NavigationService.instance.replace({ id: AppView.Inbox }); - } catch { - ErrorService.instance.notifyUser(translate('errors.identitySetup.setupFailed')); - } finally { - setIsConfirmingChange(false); - } - }; + const onConfirmChange = () => setupMailAccount({ ...newEmail, hashedPassword }); const stepContent: Record = { updateEmail: ( diff --git a/src/features/welcome/index.tsx b/src/features/welcome/index.tsx index ffe8f24..4bf5a8b 100644 --- a/src/features/welcome/index.tsx +++ b/src/features/welcome/index.tsx @@ -3,12 +3,25 @@ import SmallLogo from '../../assets/logos/Internxt/small-logo.svg?react'; import MailAppImage from '../../assets/images/welcome/welcome-page.webp'; import { useTranslationContext } from '@/i18n'; import { useAuth } from '@/hooks/auth/useAuth'; +import { useMailAccess } from '@/hooks/mail/useMailAccess'; +import { INTERNXT_BASE_URL } from '@/constants'; +import { ErrorService } from '@/services/error'; +import { ToastType } from '@/services/notifications'; import { NavigationService } from '@/services/navigation'; import { AppView } from '@/routes/paths'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; + +interface WelcomeLocationState { + planAlreadyNotified?: boolean; +} const WelcomePage = () => { const { translate } = useTranslationContext(); + const { status } = useMailAccess(); + const location = useLocation(); + const isPlanRequired = status === 'plan-required'; + const hasNotifiedPlanRequired = useRef((location.state as WelcomeLocationState | null)?.planAlreadyNotified === true); useEffect(() => { const hadDark = document.documentElement.classList.contains('dark'); @@ -18,6 +31,13 @@ const WelcomePage = () => { }; }, []); + useEffect(() => { + if (!isPlanRequired || hasNotifiedPlanRequired.current) return; + + hasNotifiedPlanRequired.current = true; + ErrorService.instance.notifyUser(translate('errors.identitySetup.planNotSupported'), ToastType.Warning); + }, [isPlanRequired, translate]); + const onSuccess = () => { NavigationService.instance.replace({ id: AppView.IdentitySetup }); }; @@ -52,6 +72,19 @@ const WelcomePage = () => {

{translate('welcome.description')}

+ {isPlanRequired && ( +

+ {translate('planRequired.notice')}{' '} + + {translate('planRequired.seePlans')} → + +

+ )}
diff --git a/src/hooks/mail/useMailAccess.test.tsx b/src/hooks/mail/useMailAccess.test.tsx new file mode 100644 index 0000000..dbe5079 --- /dev/null +++ b/src/hooks/mail/useMailAccess.test.tsx @@ -0,0 +1,151 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { Provider } from 'react-redux'; +import type { PropsWithChildren } from 'react'; +import { Service } from '@internxt/sdk/dist/drive/payments/types/tiers'; +import { useMailAccess } from './useMailAccess'; +import { createTestStore } from '@/test-utils/createTestStore'; +import { getMockedTier } from '@/test-utils/fixtures'; +import { MailService } from '@/services/sdk/mail'; +import { PaymentsService } from '@/services/sdk/payments'; +import { ErrorService } from '@/services/error'; +import { MAIL_NOT_SETUP_CODE } from '@/errors'; +import { LocalStorageService } from '@/services/local-storage'; +import { MailKeysService } from '@/services/mail-keys'; +import { openEncryptionKeystore } from 'internxt-crypto'; + +vi.mock('internxt-crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openEncryptionKeystore: vi.fn(), + }; +}); + +const mockedOpenKeystore = vi.mocked(openEncryptionKeystore); + +const createWrapper = (store: ReturnType) => { + return ({ children }: PropsWithChildren) => {children}; +}; + +const createAuthenticatedStore = () => createTestStore({ user: { isAuthenticated: true } }); + +const mockKeys = { + address: 'jane@inxt.me', + publicKey: 'pub', + encryptionPrivateKey: 'enc', + recoveryPrivateKey: 'rec', +}; + +const tierWithMail = (enabled: boolean) => { + const tier = getMockedTier(); + return { + ...tier, + featuresPerService: { + ...tier.featuresPerService, + [Service.Mail]: { ...tier.featuresPerService[Service.Mail], enabled }, + }, + }; +}; + +/** Makes the account keys request fail the way the backend rejects a user without a mail account. */ +const mockAccountNotSetUp = () => { + vi.spyOn(MailService.instance, 'getMailAccountKeys').mockRejectedValue(new Error('Forbidden')); + vi.spyOn(ErrorService.instance, 'castError').mockReturnValue({ + message: 'Mail account has not been set up', + status: 403, + code: MAIL_NOT_SETUP_CODE, + } as never); +}; + +/** Makes the account keys request succeed and the keystore open, so the account is usable. */ +const mockAccountReady = () => { + vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys); + vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic'); + mockedOpenKeystore.mockResolvedValue({ publicKey: new Uint8Array([1]), secretKey: new Uint8Array([2]) }); +}; + +describe('useMailAccess', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockedOpenKeystore.mockReset(); + MailKeysService.instance.clear(); + }); + + test('When the user is not authenticated, then it should stay in loading state and request nothing', () => { + const getKeysSpy = vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys); + const getTierSpy = vi.spyOn(PaymentsService.instance, 'getUserTier').mockResolvedValue(tierWithMail(true)); + const store = createTestStore(); + + const { result } = renderHook(() => useMailAccess(), { wrapper: createWrapper(store) }); + + expect(result.current.status).toBe('loading'); + expect(getKeysSpy).not.toHaveBeenCalled(); + expect(getTierSpy).not.toHaveBeenCalled(); + }); + + test('When the account is not set up and the plan excludes mail, then the status should be plan-required', async () => { + mockAccountNotSetUp(); + vi.spyOn(PaymentsService.instance, 'getUserTier').mockResolvedValue(tierWithMail(false)); + const store = createAuthenticatedStore(); + + const { result } = renderHook(() => useMailAccess(), { wrapper: createWrapper(store) }); + + await waitFor(() => expect(result.current.status).toBe('plan-required')); + }); + + test('When the account is not set up and the plan includes mail, then the status should be needs-setup', async () => { + mockAccountNotSetUp(); + vi.spyOn(PaymentsService.instance, 'getUserTier').mockResolvedValue(tierWithMail(true)); + const store = createAuthenticatedStore(); + + const { result } = renderHook(() => useMailAccess(), { wrapper: createWrapper(store) }); + + await waitFor(() => expect(result.current.status).toBe('needs-setup')); + }); + + test('When the tier cannot be fetched, then setup should not be blocked', async () => { + mockAccountNotSetUp(); + vi.spyOn(PaymentsService.instance, 'getUserTier').mockRejectedValue(new Error('payments is down')); + const store = createAuthenticatedStore(); + + const { result } = renderHook(() => useMailAccess(), { wrapper: createWrapper(store) }); + + await waitFor(() => expect(result.current.status).toBe('needs-setup')); + }); + + test('When the tier is still being fetched, then it should stay in loading state', async () => { + mockAccountNotSetUp(); + vi.spyOn(PaymentsService.instance, 'getUserTier').mockReturnValue(new Promise(() => undefined)); + const store = createAuthenticatedStore(); + + const { result } = renderHook(() => useMailAccess(), { wrapper: createWrapper(store) }); + + await waitFor(() => expect(PaymentsService.instance.getUserTier).toHaveBeenCalled()); + expect(result.current.status).toBe('loading'); + }); + + test('When the account exists but the plan no longer includes mail, then access should be kept during the grace period', async () => { + mockAccountReady(); + const getTierSpy = vi.spyOn(PaymentsService.instance, 'getUserTier').mockResolvedValue(tierWithMail(false)); + const store = createAuthenticatedStore(); + + const { result } = renderHook(() => useMailAccess(), { wrapper: createWrapper(store) }); + + await waitFor(() => expect(result.current.status).toBe('ready')); + expect(getTierSpy).not.toHaveBeenCalled(); + }); + + test('When the account keys cannot be read, then the status should be error', async () => { + vi.spyOn(MailService.instance, 'getMailAccountKeys').mockRejectedValue(new Error('Network error')); + vi.spyOn(ErrorService.instance, 'castError').mockReturnValue({ + message: 'Network error', + status: 500, + } as never); + const store = createAuthenticatedStore(); + + const { result } = renderHook(() => useMailAccess(), { wrapper: createWrapper(store) }); + + await waitFor(() => expect(result.current.status).toBe('error')); + }); +}); diff --git a/src/hooks/mail/useMailAccess.ts b/src/hooks/mail/useMailAccess.ts new file mode 100644 index 0000000..4209b99 --- /dev/null +++ b/src/hooks/mail/useMailAccess.ts @@ -0,0 +1,36 @@ +import { Service } from '@internxt/sdk/dist/drive/payments/types/tiers'; +import { useGetUserTierQuery } from '@/store/api/payments'; +import { useAppSelector } from '@/store/hooks'; +import { useMailAccountGuard } from './useMailAccountGuard'; +import type { RootState } from '@/store'; + +export type MailAccessStatus = 'loading' | 'ready' | 'needs-setup' | 'plan-required' | 'error'; + +/** + * Resolves whether the user can reach the mailbox, still has to set it up, or is on a plan that + * does not include Mail. The plan is only consulted for users without a mail account: someone who + * already provisioned Mail and then downgraded keeps access during the grace period that precedes + * the account deletion. + */ +export const useMailAccess = (): { status: MailAccessStatus } => { + const isAuthenticated = useAppSelector((state: RootState) => state.user.isAuthenticated); + const { status: accountStatus } = useMailAccountGuard(); + const isSetupPending = accountStatus === 'not-setup'; + + const { data: tier, isLoading: isTierLoading } = useGetUserTierQuery(undefined, { + skip: !isAuthenticated || !isSetupPending, + }); + + if (!isAuthenticated) return { status: 'loading' }; + if (accountStatus === 'ready') return { status: 'ready' }; + if (accountStatus === 'error') return { status: 'error' }; + if (accountStatus === 'loading') return { status: 'loading' }; + + if (isTierLoading) return { status: 'loading' }; + + // A failed tier request must not block setup: the provisioning endpoint rejects ineligible + // plans anyway, and that error is surfaced to the user. + if (tier?.featuresPerService[Service.Mail]?.enabled === false) return { status: 'plan-required' }; + + return { status: 'needs-setup' }; +}; diff --git a/src/hooks/mail/useMailAccountGuard.test.tsx b/src/hooks/mail/useMailAccountGuard.test.tsx index 59f01e5..8292255 100644 --- a/src/hooks/mail/useMailAccountGuard.test.tsx +++ b/src/hooks/mail/useMailAccountGuard.test.tsx @@ -41,7 +41,7 @@ describe('useMailAccountGuard', () => { test('When the keys query is in flight, then it should stay in loading state', () => { vi.spyOn(MailService.instance, 'getMailAccountKeys').mockReturnValue(new Promise(() => undefined)); - const store = createTestStore(); + const store = createTestStore({ user: { isAuthenticated: true } }); const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) }); @@ -53,7 +53,7 @@ describe('useMailAccountGuard', () => { vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic'); const decryptedKeys = { publicKey: new Uint8Array([1]), secretKey: new Uint8Array([2]) }; mockedOpenKeystore.mockResolvedValue(decryptedKeys); - const store = createTestStore(); + const store = createTestStore({ user: { isAuthenticated: true } }); const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) }); @@ -65,7 +65,7 @@ describe('useMailAccountGuard', () => { vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys); vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic'); mockedOpenKeystore.mockRejectedValue(new Error('bad keystore')); - const store = createTestStore(); + const store = createTestStore({ user: { isAuthenticated: true } }); const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) }); @@ -77,7 +77,7 @@ describe('useMailAccountGuard', () => { vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic'); const decryptedKeys = { publicKey: new Uint8Array([1]), secretKey: new Uint8Array([2]) }; mockedOpenKeystore.mockRejectedValueOnce(new Error('bad keystore')).mockResolvedValueOnce(decryptedKeys); - const store = createTestStore(); + const store = createTestStore({ user: { isAuthenticated: true } }); const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) }); @@ -93,7 +93,7 @@ describe('useMailAccountGuard', () => { status: 403, code: MAIL_NOT_SETUP_CODE, } as never); - const store = createTestStore(); + const store = createTestStore({ user: { isAuthenticated: true } }); const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) }); @@ -105,7 +105,7 @@ describe('useMailAccountGuard', () => { const getMnemonicSpy = vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue('mnemonic'); const cachedKeys = { publicKey: new Uint8Array([9]), secretKey: new Uint8Array([8]) }; MailKeysService.instance.set(mockKeys.address, cachedKeys); - const store = createTestStore(); + const store = createTestStore({ user: { isAuthenticated: true } }); const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) }); @@ -118,7 +118,7 @@ describe('useMailAccountGuard', () => { test('When the mnemonic is missing, then the status should be error and no keystore should be loaded', async () => { vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys); vi.spyOn(LocalStorageService.instance, 'getMnemonic').mockReturnValue(null as unknown as string); - const store = createTestStore(); + const store = createTestStore({ user: { isAuthenticated: true } }); const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) }); @@ -133,10 +133,20 @@ describe('useMailAccountGuard', () => { message: 'Network error', status: 500, } as never); - const store = createTestStore(); + const store = createTestStore({ user: { isAuthenticated: true } }); const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) }); await waitFor(() => expect(result.current.status).toBe('error')); }); + + test('When the user is not authenticated, then the keys should not be requested', () => { + const getKeysSpy = vi.spyOn(MailService.instance, 'getMailAccountKeys').mockResolvedValue(mockKeys); + const store = createTestStore(); + + const { result } = renderHook(() => useMailAccountGuard(), { wrapper: createWrapper(store) }); + + expect(getKeysSpy).not.toHaveBeenCalled(); + expect(result.current.status).toBe('loading'); + }); }); diff --git a/src/hooks/mail/useMailAccountGuard.ts b/src/hooks/mail/useMailAccountGuard.ts index e691d27..690cbe9 100644 --- a/src/hooks/mail/useMailAccountGuard.ts +++ b/src/hooks/mail/useMailAccountGuard.ts @@ -4,11 +4,13 @@ import { useGetMailAccountKeysQuery } from '@/store/api/mail'; import { MailNotSetupError } from '@/errors'; import { LocalStorageService } from '@/services/local-storage'; import { MailKeysService } from '@/services/mail-keys'; +import { useAppSelector } from '@/store/hooks'; export type MailAccountGuardStatus = 'loading' | 'ready' | 'not-setup' | 'error'; export const useMailAccountGuard = (): { status: MailAccountGuardStatus } => { - const { data, error, isLoading, isFetching } = useGetMailAccountKeysQuery(); + const isAuthenticated = useAppSelector((state) => state.user.isAuthenticated); + const { data, error, isLoading, isFetching } = useGetMailAccountKeysQuery(undefined, { skip: !isAuthenticated }); const lastStartedAddress = useRef(null); const [isDecrypted, setIsDecrypted] = useState(false); const [decryptError, setDecryptError] = useState(false); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8664be3..9e9ae05 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -26,6 +26,10 @@ "message": "You downgraded to a plan that doesn't support Internxt Mail. Your account will be deleted in {{days}} days.", "upgrade": "Upgrade" }, + "planRequired": { + "notice": "Mail isn't part of your plan.", + "seePlans": "See plans" + }, "filter": { "all": "All", "none": "None", @@ -175,7 +179,8 @@ "availabilityCheckFailed": "Something went wrong while checking the address availability. Please try again.", "availabilityCheckRateLimited": "An error occurred while checking the address availability. Please wait a moment and try again.", "passwordCheckFailed": "Something went wrong while verifying your password", - "setupFailed": "Something went wrong while setting up your account" + "setupFailed": "Something went wrong while setting up your account", + "planNotSupported": "Your current plan doesn't include Internxt Mail. Upgrade to finish setting up your address." }, "downloadingDesktopApp": "Something went wrong while downloading the app", "mail": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 04c6a12..4249aa7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -26,6 +26,10 @@ "message": "Has bajado a un plan que no incluye Internxt Mail. Tu cuenta se eliminará en {{days}} días.", "upgrade": "Mejorar plan" }, + "planRequired": { + "notice": "Mail no está incluido en tu plan.", + "seePlans": "Ver planes" + }, "filter": { "all": "Todos", "none": "Ninguno", @@ -177,7 +181,8 @@ "availabilityCheckFailed": "Algo salió mal al comprobar la disponibilidad de la dirección. Por favor, inténtalo de nuevo.", "availabilityCheckRateLimited": "Hubo un error al comprobar la disponibilidad de la dirección. Por favor, espera un momento e inténtalo de nuevo.", "passwordCheckFailed": "Algo ha ido mal al verificar tu contraseña", - "setupFailed": "Algo ha ido mal al configurar tu cuenta" + "setupFailed": "Algo ha ido mal al configurar tu cuenta", + "planNotSupported": "Tu plan actual no incluye Internxt Mail. Mejora tu plan para terminar de configurar tu dirección." }, "downloadingDesktopApp": "Algo ha ido mal al descargar la aplicación", "mail": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 237b38f..0a3498c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -26,6 +26,10 @@ "message": "Vous êtes passé à un plan qui ne prend pas en charge Internxt Mail. Votre compte sera supprimé dans {{days}} jours.", "upgrade": "Mettre à niveau" }, + "planRequired": { + "notice": "Mail n'est pas inclus dans votre forfait.", + "seePlans": "Voir les forfaits" + }, "filter": { "all": "Tous", "none": "Aucun", @@ -177,7 +181,8 @@ "availabilityCheckFailed": "Une erreur s'est produite lors de la vérification de la disponibilité de l'adresse. Veuillez réessayer.", "availabilityCheckRateLimited": "Une erreur s'est produite lors de la vérification de la disponibilité de l'adresse. Veuillez patienter un instant et réessayer.", "passwordCheckFailed": "Une erreur s'est produite lors de la vérification de votre mot de passe", - "setupFailed": "Une erreur s'est produite lors de la configuration de votre compte" + "setupFailed": "Une erreur s'est produite lors de la configuration de votre compte", + "planNotSupported": "Votre forfait actuel n'inclut pas Internxt Mail. Passez à un forfait supérieur pour terminer la configuration de votre adresse." }, "downloadingDesktopApp": "Une erreur s'est produite lors du téléchargement de l'application", "mail": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index f93f19e..8801015 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -26,6 +26,10 @@ "message": "Sei passato a un piano che non supporta Internxt Mail. Il tuo account verrà eliminato tra {{days}} giorni.", "upgrade": "Aggiorna piano" }, + "planRequired": { + "notice": "Mail non è incluso nel tuo piano.", + "seePlans": "Vedi i piani" + }, "filter": { "all": "Tutti", "none": "Nessuno", @@ -177,7 +181,8 @@ "availabilityCheckFailed": "Qualcosa è andato storto durante il controllo della disponibilità dell'indirizzo. Riprova.", "availabilityCheckRateLimited": "Si è verificato un errore durante il controllo della disponibilità dell'indirizzo. Attendi un momento e riprova.", "passwordCheckFailed": "Si è verificato un errore durante la verifica della password", - "setupFailed": "Si è verificato un errore durante la configurazione del tuo account" + "setupFailed": "Si è verificato un errore durante la configurazione del tuo account", + "planNotSupported": "Il tuo piano attuale non include Internxt Mail. Passa a un piano superiore per completare la configurazione del tuo indirizzo." }, "downloadingDesktopApp": "Si è verificato un errore durante il download dell'app", "mail": { diff --git a/src/routes/guards/ProtectedRoute.test.tsx b/src/routes/guards/ProtectedRoute.test.tsx new file mode 100644 index 0000000..9f8893c --- /dev/null +++ b/src/routes/guards/ProtectedRoute.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { ProtectedRoute } from './ProtectedRoute'; +import { createTestStore } from '@/test-utils/createTestStore'; +import { useMailAccess, type MailAccessStatus } from '@/hooks/mail/useMailAccess'; + +vi.mock('@/hooks/mail/useMailAccess', () => ({ useMailAccess: vi.fn() })); + +const mockedUseMailAccess = vi.mocked(useMailAccess); + +const renderAt = ({ + status, + path, + isAuthenticated = true, +}: { + status: MailAccessStatus; + path: string; + isAuthenticated?: boolean; +}) => { + mockedUseMailAccess.mockReturnValue({ status }); + const store = createTestStore({ user: { isAuthenticated } }); + + return render( + + + + welcome page

} /> + }> + inbox page

} /> + identity setup page

} /> +
+
+
+
, + ); +}; + +describe('ProtectedRoute', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('When the user is not authenticated, then it should send them to the welcome page', () => { + renderAt({ status: 'loading', path: '/inbox', isAuthenticated: false }); + + expect(screen.getByText('welcome page')).toBeTruthy(); + }); + + test('When the plan does not include mail, then it should send them to the welcome page instead of the setup flow', () => { + renderAt({ status: 'plan-required', path: '/inbox' }); + + expect(screen.getByText('welcome page')).toBeTruthy(); + expect(screen.queryByText('identity setup page')).toBeNull(); + }); + + test('When the plan does not include mail, then it should not let them open the setup flow directly', () => { + renderAt({ status: 'plan-required', path: '/identity-setup' }); + + expect(screen.getByText('welcome page')).toBeTruthy(); + expect(screen.queryByText('identity setup page')).toBeNull(); + }); + + test('When the account still has to be set up, then it should send them to the setup flow', () => { + renderAt({ status: 'needs-setup', path: '/inbox' }); + + expect(screen.getByText('identity setup page')).toBeTruthy(); + }); + + test('When the account is ready, then it should render the requested view', () => { + renderAt({ status: 'ready', path: '/inbox' }); + + expect(screen.getByText('inbox page')).toBeTruthy(); + }); + + test('When the account is ready and they are on the setup flow, then it should send them to the inbox', () => { + renderAt({ status: 'ready', path: '/identity-setup' }); + + expect(screen.getByText('inbox page')).toBeTruthy(); + }); + + test('When the access status is still loading, then it should render nothing', () => { + const { container } = renderAt({ status: 'loading', path: '/inbox' }); + + expect(container.textContent).toBe(''); + }); +}); diff --git a/src/routes/guards/ProtectedRoute.tsx b/src/routes/guards/ProtectedRoute.tsx index 567cbe4..35d6c20 100644 --- a/src/routes/guards/ProtectedRoute.tsx +++ b/src/routes/guards/ProtectedRoute.tsx @@ -1,16 +1,17 @@ import { useAppSelector } from '@/store/hooks'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; import { AppView, getRouteConfig } from '../paths'; -import { useMailAccountGuard } from '@/hooks/mail/useMailAccountGuard'; +import { useMailAccess } from '@/hooks/mail/useMailAccess'; export const ProtectedRoute = () => { const { isAuthenticated } = useAppSelector((state) => state.user); const location = useLocation(); - const { status } = useMailAccountGuard(); + const { status } = useMailAccess(); + + const welcomePath = getRouteConfig(AppView.Welcome).path; if (!isAuthenticated) { - const to = getRouteConfig(AppView.Welcome).path; - return ; + return ; } const identitySetupPath = getRouteConfig(AppView.IdentitySetup).path; @@ -18,7 +19,11 @@ export const ProtectedRoute = () => { if (status === 'loading') return null; - if (status === 'not-setup' && !isOnIdentitySetup) { + if (status === 'plan-required') { + return ; + } + + if (status === 'needs-setup' && !isOnIdentitySetup) { return ; } diff --git a/src/routes/guards/PublicRoute.test.tsx b/src/routes/guards/PublicRoute.test.tsx new file mode 100644 index 0000000..f0c7045 --- /dev/null +++ b/src/routes/guards/PublicRoute.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { PublicRoute } from './PublicRoute'; +import { createTestStore } from '@/test-utils/createTestStore'; +import { useMailAccess, type MailAccessStatus } from '@/hooks/mail/useMailAccess'; + +vi.mock('@/hooks/mail/useMailAccess', () => ({ useMailAccess: vi.fn() })); + +const mockedUseMailAccess = vi.mocked(useMailAccess); + +const renderWelcome = ({ status, isAuthenticated }: { status: MailAccessStatus; isAuthenticated: boolean }) => { + mockedUseMailAccess.mockReturnValue({ status }); + const store = createTestStore({ user: { isAuthenticated } }); + + return render( + + + + }> + welcome page

} /> +
+ inbox page

} /> +
+
+
, + ); +}; + +describe('PublicRoute', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('When the user is not authenticated, then it should render the welcome page', () => { + renderWelcome({ status: 'loading', isAuthenticated: false }); + + expect(screen.getByText('welcome page')).toBeTruthy(); + }); + + test('When the plan does not include mail, then it should keep the user on the welcome page', () => { + renderWelcome({ status: 'plan-required', isAuthenticated: true }); + + expect(screen.getByText('welcome page')).toBeTruthy(); + expect(screen.queryByText('inbox page')).toBeNull(); + }); + + test('When the account is ready, then it should send the user to the inbox', () => { + renderWelcome({ status: 'ready', isAuthenticated: true }); + + expect(screen.getByText('inbox page')).toBeTruthy(); + }); + + test('When the account still has to be set up, then it should send the user into the app', () => { + renderWelcome({ status: 'needs-setup', isAuthenticated: true }); + + expect(screen.getByText('inbox page')).toBeTruthy(); + }); + + test('When the access status is still loading, then it should render nothing', () => { + const { container } = renderWelcome({ status: 'loading', isAuthenticated: true }); + + expect(container.textContent).toBe(''); + }); +}); diff --git a/src/routes/guards/PublicRoute.tsx b/src/routes/guards/PublicRoute.tsx index 0d52421..87780c3 100644 --- a/src/routes/guards/PublicRoute.tsx +++ b/src/routes/guards/PublicRoute.tsx @@ -1,14 +1,24 @@ import { useAppSelector } from '@/store/hooks'; import { AppView, getRouteConfig } from '../paths'; import { Navigate, Outlet } from 'react-router-dom'; +import { useMailAccess } from '@/hooks/mail/useMailAccess'; export const PublicRoute = () => { const { isAuthenticated } = useAppSelector((state) => state.user); + const { status } = useMailAccess(); - if (isAuthenticated) { - const to = getRouteConfig(AppView.Inbox).path; - return ; + if (!isAuthenticated) { + return ; } - return ; + if (status === 'loading') return null; + + // The welcome page doubles as the upgrade screen, so an authenticated user whose plan lacks + // Mail stays here instead of being sent to a mailbox they cannot open. + if (status === 'plan-required') { + return ; + } + + const to = getRouteConfig(AppView.Inbox).path; + return ; }; diff --git a/src/store/api/base.ts b/src/store/api/base.ts index 2a722b3..f60e0de 100644 --- a/src/store/api/base.ts +++ b/src/store/api/base.ts @@ -11,6 +11,7 @@ export const api = createApi({ 'MailMe', 'StorageUsage', 'StorageLimit', + 'UserTier', 'RecipientKeys', 'ActiveDomains', 'ThreadMessage', diff --git a/src/store/api/payments/index.ts b/src/store/api/payments/index.ts new file mode 100644 index 0000000..134b351 --- /dev/null +++ b/src/store/api/payments/index.ts @@ -0,0 +1,24 @@ +import type { Tier } from '@internxt/sdk/dist/drive/payments/types/tiers'; +import { FetchUserTierError } from '@/errors'; +import { ErrorService } from '@/services/error'; +import { PaymentsService } from '@/services/sdk/payments'; +import { api } from '../base'; + +export const paymentsApi = api.injectEndpoints({ + endpoints: (builder) => ({ + getUserTier: builder.query({ + async queryFn() { + try { + const tier = await PaymentsService.instance.getUserTier(); + return { data: tier }; + } catch (error) { + const err = ErrorService.instance.castError(error); + return { error: new FetchUserTierError(err.message, err.requestId) }; + } + }, + providesTags: ['UserTier'], + }), + }), +}); + +export const { useGetUserTierQuery } = paymentsApi;