Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@internxt/sdk",
"author": "Internxt <hello@internxt.com>",
"version": "1.22.0",
"version": "1.22.1",
"description": "An sdk for interacting with Internxt's services",
"repository": {
"type": "git",
Expand Down
61 changes: 48 additions & 13 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { ApiSecurity, ApiUrl, AppDetails } from '../shared';
import { basicHeaders, headersWithToken } from '../shared/headers';
import { HttpClient } from '../shared/http/client';
import { TeamsSettings } from '../shared/types/teams';
import { UserSettings, UUID } from '../shared/types/userSettings';
import { UserSettings } from '../shared/types/userSettings';
import {
ChangePasswordWithLinkPayload,
CompleteAccountSetupPayload,
CryptoProvider,
Keys,
LoginDetails,
Expand All @@ -13,6 +14,7 @@ import {
RegisterDetails,
RegisterPreCreatedUser,
RegisterPreCreatedUserResponse,
RegisterResponse,
SecurityDetails,
Token,
TwoFactorAuthQR,
Expand Down Expand Up @@ -42,12 +44,7 @@ export class Auth {
* Tries to register a new user
* @param registerDetails
*/
public register(registerDetails: RegisterDetails): Promise<{
token: Token;
newToken: Token;
user: Omit<UserSettings, 'bucket'> & { referralCode: string };
uuid: UUID;
}> {
public register(registerDetails: RegisterDetails): Promise<RegisterResponse> {
return this.client.post(
'/users',
{
Expand Down Expand Up @@ -79,12 +76,7 @@ export class Auth {
* Tries to register a new user without sending keys
* @param registerDetails
*/
public registerWithoutKeys(registerDetails: Omit<RegisterDetails, 'keys'>): Promise<{
token: Token;
newToken: Token;
user: Omit<UserSettings, 'bucket'> & { referralCode: string };
uuid: UUID;
}> {
public registerWithoutKeys(registerDetails: Omit<RegisterDetails, 'keys'>): Promise<RegisterResponse> {
const body = {
name: registerDetails.name,
captcha: registerDetails.captcha,
Expand Down Expand Up @@ -159,6 +151,49 @@ export class Auth {
);
}

/**
* Completes the setup of an account that was created after a payment, setting its password and keys.
* The client must be created with the captcha token in the `x-internxt-captcha` custom header.
* @param setupDetails - The setup token received by email and the user's encrypted credentials and keys
* @returns {Promise<RegisterResponse>} Returns sign in token, user data and uuid.
*/
public completeAccountSetup(setupDetails: CompleteAccountSetupPayload): Promise<RegisterResponse> {
return this.client.post(
'users/pre-created-users/complete-setup',
{
token: setupDetails.token,
name: setupDetails.name,
lastname: setupDetails.lastname,
password: setupDetails.password,
mnemonic: setupDetails.mnemonic,
salt: setupDetails.salt,
keys: {
ecc: {
publicKey: setupDetails.keys.ecc.publicKey,
privateKey: setupDetails.keys.ecc.privateKeyEncrypted,
},
kyber: {
publicKey: setupDetails.keys.kyber.publicKey,
privateKey: setupDetails.keys.kyber.privateKeyEncrypted,
},
},
referrer: setupDetails.referrer,
},
this.basicHeaders(),
);
}

/**
* Requests a new email to complete the setup of an account that was created after a payment.
* The client must be created with the captcha token in the `x-internxt-captcha` custom header.
*
* @param {string} email - The email address associated with the account.
* @returns {Promise<void>} - Resolves whether or not the email has a pending setup.
*/
public resendAccountSetupEmail(email: string): Promise<void> {
return this.client.post('users/pre-created-users/setup-email', { email }, this.basicHeaders());
}

/**
* Requests account unblock email
*
Expand Down
11 changes: 11 additions & 0 deletions src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ export interface RegisterDetails {
referral?: string;
}

export interface RegisterResponse {
token: Token;
newToken: Token;
user: Omit<UserSettings, 'bucket'> & { referralCode: string };
uuid: UUID;
}

export interface CompleteAccountSetupPayload extends Omit<RegisterDetails, 'email' | 'captcha' | 'referral'> {
token: string;
}

export interface RegisterPreCreatedUser extends RegisterDetails {
invitationId: string;
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from './auth';
export { AppError } from './shared';
export { AppError, ACCOUNT_SETUP_PENDING_ERROR_CODE, isAccountSetupPendingError } from './shared';
export * as Drive from './drive';
export * as Network from './network';
export * as Shared from './shared';
Expand Down
55 changes: 51 additions & 4 deletions src/payments/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { basicHeaders, headersWithToken } from '../shared/headers';
import { HttpClient } from '../shared/http/client';
import {
CreateCustomerPayload,
CreateCustomerWithoutAccountPayload,
CreatedCustomer,
CreatePaymentIntentPayload,
CreateSubscriptionPayload,
CryptoCurrency,
Expand Down Expand Up @@ -50,10 +52,7 @@ export class Checkout {
captchaToken,
companyVatId,
metadata,
}: CreateCustomerPayload): Promise<{
customerId: string;
token: string;
}> {
}: CreateCustomerPayload): Promise<CreatedCustomer> {
return this.client.post(
'/checkout/customer',
{
Expand All @@ -71,6 +70,54 @@ export class Checkout {
);
}

/**
* @description Creates a customer, or gets the existing one, for someone who does not have a Drive account yet.
* The request is sent without the user authorization header; the Drive account is created once the payment succeeds.
* @param email - The email of the customer, used later to create the Drive account
* @param confirmationTokenId - The ID of the Stripe ConfirmationToken created with the payment details
* @param country - The country of the customer
* @param captchaToken - The captcha token to verify the call
* @param customerName - The name of the customer (optional)
* @param lineAddress1 - The address of the user (optional)
* @param lineAddress2 - The second address line of the user (optional)
* @param city - The city of the user (optional)
* @param postalCode - The postal code of the customer (optional)
* @param companyVatId - The VAT ID of the company (optional)
* @param metadata - Additional metadata to attach to the customer (optional)
* @returns The customer ID and the user token used to create a subscription or payment intent
*/
public createCustomerWithoutAccount({
email,
confirmationTokenId,
customerName,
lineAddress1,
lineAddress2,
postalCode,
city,
country,
captchaToken,
companyVatId,
metadata,
}: CreateCustomerWithoutAccountPayload): Promise<CreatedCustomer> {
return this.client.post(
'/checkout/customer',
{
email,
confirmationTokenId,
customerName,
city,
lineAddress1,
lineAddress2,
country,
postalCode,
captchaToken,
companyVatId,
metadata,
},
this.headers(),
);
}

/**
* @description Creates a subscription for a given customer
* @param customerId - The ID of the customer
Expand Down
10 changes: 10 additions & 0 deletions src/payments/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ export interface CreateCustomerPayload {
metadata?: Record<string, string>;
}

export interface CreateCustomerWithoutAccountPayload extends CreateCustomerPayload {
email: string;
confirmationTokenId: string;
}

export interface CreatedCustomer {
customerId: string;
token: string;
}

export interface CreateSubscriptionPayload {
customerId: string;
priceId: string;
Expand Down
2 changes: 1 addition & 1 deletion src/shared/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from './types/apiConnection';
export { default as AppError } from './types/errors';
export { default as AppError, ACCOUNT_SETUP_PENDING_ERROR_CODE, isAccountSetupPendingError } from './types/errors';
export { HttpClient } from './http/client';
export { retryWithBackoff } from './http/retryWithBackoff';
export type { RetryOptions } from './http/retryWithBackoff';
13 changes: 13 additions & 0 deletions src/shared/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,16 @@ export class AxiosUnknownError extends Error {
Object.setPrototypeOf(this, AxiosUnknownError.prototype);
}
}

export const ACCOUNT_SETUP_PENDING_ERROR_CODE = 'AccountSetupPending';

export function isAccountSetupPendingError(error: unknown): boolean {
if (error instanceof AppError) {
return error.code === ACCOUNT_SETUP_PENDING_ERROR_CODE;
}
if (error instanceof AxiosResponseError) {
const responseBody = error.data as { code?: unknown } | undefined;
return responseBody?.code === ACCOUNT_SETUP_PENDING_ERROR_CODE;
}
return false;
}
132 changes: 131 additions & 1 deletion test/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,20 @@ import { emptyRegisterDetails } from './registerDetails.mother';
import { basicHeaders, headersWithToken } from '../../src/shared/headers';
import { ApiSecurity, AppDetails } from '../../src/shared';
import { HttpClient } from '../../src/shared/http/client';
import { Auth, CryptoProvider, Keys, LoginDetails, RegisterDetails, Token } from '../../src';
import {
ACCOUNT_SETUP_PENDING_ERROR_CODE,
AppError,
Auth,
CompleteAccountSetupPayload,
CryptoProvider,
isAccountSetupPendingError,
Keys,
LoginDetails,
RegisterDetails,
Token,
} from '../../src';
import { AxiosResponseError } from '../../src/shared/types/errors';
import { AxiosResponse } from 'axios';

describe('# auth service tests', () => {
beforeEach(() => {
Expand Down Expand Up @@ -746,6 +759,103 @@ describe('# auth service tests', () => {
});
});

describe('Account setup after payment', () => {
const captchaToken = 'captcha-token';

function setupDetails(): CompleteAccountSetupPayload {
return {
token: 'setup-token',
name: 'Jane',
lastname: 'Doe',
password: 'encrypted-password',
mnemonic: 'encrypted-mnemonic',
salt: 'encrypted-salt',
keys: {
ecc: { publicKey: 'ecc-public', privateKeyEncrypted: 'ecc-private' },
kyber: { publicKey: 'kyber-public', privateKeyEncrypted: 'kyber-private' },
},
referrer: 'referrer-code',
};
}

it('When the user completes the setup, then the token, credentials and keys go with the captcha', async () => {
const postCall = vi.spyOn(HttpClient.prototype, 'post').mockResolvedValue({});
const { client, headers } = clientAndHeadersWithCaptcha(captchaToken);

await client.completeAccountSetup(setupDetails());

expect(postCall).toHaveBeenCalledWith(
'users/pre-created-users/complete-setup',
{
token: 'setup-token',
name: 'Jane',
lastname: 'Doe',
password: 'encrypted-password',
mnemonic: 'encrypted-mnemonic',
salt: 'encrypted-salt',
keys: {
ecc: { publicKey: 'ecc-public', privateKey: 'ecc-private' },
kyber: { publicKey: 'kyber-public', privateKey: 'kyber-private' },
},
referrer: 'referrer-code',
},
headers,
);
expect(postCall.mock.calls[0][2]).toHaveProperty('x-internxt-captcha', captchaToken);
});

it('When the setup is completed, then the user gets the same session data as after signing up', async () => {
const sessionData = { token: 'token', newToken: 'new-token', user: { email: 'jane@internxt.com' }, uuid: 'uuid' };
vi.spyOn(HttpClient.prototype, 'post').mockResolvedValue(sessionData);
const { client } = clientAndHeadersWithCaptcha(captchaToken);

const session = await client.completeAccountSetup(setupDetails());

expect(session).toStrictEqual(sessionData);
});

it('When the user asks for the setup email again, then the email is sent with the captcha', async () => {
const postCall = vi.spyOn(HttpClient.prototype, 'post').mockResolvedValue(undefined);
const { client, headers } = clientAndHeadersWithCaptcha(captchaToken);

await client.resendAccountSetupEmail('jane@internxt.com');

expect(postCall).toHaveBeenCalledWith(
'users/pre-created-users/setup-email',
{ email: 'jane@internxt.com' },
headers,
);
expect(postCall.mock.calls[0][2]).toHaveProperty('x-internxt-captcha', captchaToken);
});

it('When logging in to an account pending setup, then the error is recognised as a pending setup', async () => {
vi.spyOn(HttpClient.prototype, 'post').mockRejectedValue(
forbiddenResponseError({ message: 'The account setup is pending', code: ACCOUNT_SETUP_PENDING_ERROR_CODE }),
);
const { client } = clientAndHeaders();

const loginError = await client.securityDetails('jane@internxt.com').catch((error: unknown) => error);

expect(isAccountSetupPendingError(loginError)).toBe(true);
});

it('When the app error carries the pending setup code, then it is recognised as a pending setup', () => {
const appError = new AppError('The account setup is pending', 403, ACCOUNT_SETUP_PENDING_ERROR_CODE);

expect(isAccountSetupPendingError(appError)).toBe(true);
});

it('When login is forbidden for another reason, then it is not recognised as a pending setup', async () => {
vi.spyOn(HttpClient.prototype, 'post').mockRejectedValue(forbiddenResponseError({ message: 'Forbidden' }));
const { client } = clientAndHeaders();

const loginError = await client.securityDetails('jane@internxt.com').catch((error: unknown) => error);

expect(isAccountSetupPendingError(loginError)).toBe(false);
expect(isAccountSetupPendingError(new Error('Network error'))).toBe(false);
});
});

describe('-> send email unblock account', () => {
it('Should call with right params & return values', async () => {
// Arrange
Expand Down Expand Up @@ -1028,3 +1138,23 @@ function clientAndHeadersWithToken(
const headers = headersWithToken({ clientName, clientVersion, token });
return { client, headers };
}

function clientAndHeadersWithCaptcha(
captchaToken: string,
clientName = 'c-name',
clientVersion = '0.1',
): {
client: Auth;
headers: object;
} {
const customHeaders = { 'x-internxt-captcha': captchaToken };
const appDetails: AppDetails = { clientName, clientVersion, customHeaders };
const client = Auth.client('', appDetails);
const headers = basicHeaders({ clientName, clientVersion, customHeaders });
return { client, headers };
}

function forbiddenResponseError(responseBody: object): AxiosResponseError {
const response = { status: 403, data: responseBody, headers: {} } as AxiosResponse;
return new AxiosResponseError('Request failed with status code 403', 'post /auth/login', response);
}
Loading
Loading