Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -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';
11 changes: 11 additions & 0 deletions src/errors/payments/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
137 changes: 137 additions & 0 deletions src/features/identity-setup/hooks/useSetupMailAccount.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('internxt-crypto')>();
return {
...actual,
createEncryptionAndRecoveryKeystores: vi.fn(),
};
});

vi.mock('@/i18n', () => ({
useTranslationContext: () => ({ translate: (key: string) => key }),
}));

const mockedCreateKeystores = vi.mocked(createEncryptionAndRecoveryKeystores);

const createWrapper = (store: ReturnType<typeof createTestStore>) => {
return ({ children }: PropsWithChildren) => <Provider store={store}>{children}</Provider>;
};

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<void> = 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);
});
});
83 changes: 83 additions & 0 deletions src/features/identity-setup/hooks/useSetupMailAccount.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(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 };
};
52 changes: 4 additions & 48 deletions src/features/identity-setup/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -28,13 +22,12 @@ const IdentitySetup = () => {
address: '',
domain: '',
});
const [isConfirmingChange, setIsConfirmingChange] = useState<boolean>(false);
const [hashedPassword, setHashedPassword] = useState<string>('');
const [step, setStep] = useState<Step>('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 {
Expand All @@ -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<Step, ReactNode> = {
updateEmail: (
Expand Down
35 changes: 34 additions & 1 deletion src/features/welcome/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 });
};
Expand Down Expand Up @@ -52,6 +72,19 @@ const WelcomePage = () => {
</h1>
<p className="tex-xl text-gray-50">{translate('welcome.description')}</p>
<div className="w-full border border-gray-10" />
{isPlanRequired && (
<p className="text-gray-60">
{translate('planRequired.notice')}{' '}
<a
className="text-primary underline"
href={`${INTERNXT_BASE_URL}/pricing`}
target="_blank"
rel="noopener noreferrer"
>
{translate('planRequired.seePlans')} →
</a>
</p>
)}
</div>

<div className="flex translate-x-50">
Expand Down
Loading
Loading