Skip to content

Commit

Permalink
Merge pull request #41 from Foxy/feature/customer-registration
Browse files Browse the repository at this point in the history
feat: add support for Customer Registration API
  • Loading branch information
brettflorio authored Dec 14, 2023
2 parents 3a18d5c + bd456a1 commit ea24467
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 5 deletions.
14 changes: 14 additions & 0 deletions src/backend/Graph/customer_portal_settings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ export interface CustomerPortalSettings extends Graph {
ssoSecret?: string;
/** Life span of session in minutes. Maximum 40320 (4 weeks). */
sessionLifespanInMinutes: number;
/** Self-registration settings. Self-registration is disabled if this field is undefined. */
signUp?: {
/** If this field is true, then self-registration is enabled. */
enabled: boolean;
/** Client verification settings. */
verification: {
/** Verification type. Currently only hCaptcha is supported. */
type: 'hcaptcha';
/** hCaptcha site key. If empty, Foxy will use its own hCaptcha site key. */
siteKey: string;
/** hCaptcha secret key. If empty, Foxy will use its own hCaptcha secret key. */
secretKey: string;
};
};
/** The date this resource was created. */
date_created: string | null;
/** The date this resource was last modified. */
Expand Down
10 changes: 10 additions & 0 deletions src/core/API/AuthError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ type UniversalAPIAuthErrorCode =
| typeof AuthError['NEW_PASSWORD_REQUIRED']
| typeof AuthError['INVALID_NEW_PASSWORD']
| typeof AuthError['UNAUTHORIZED']
| typeof AuthError['INVALID_FORM']
| typeof AuthError['UNAVAILABLE']
| typeof AuthError['UNKNOWN'];

/**
Expand All @@ -29,6 +31,12 @@ export class AuthError extends Error {
/** Credentials are invalid. That could mean empty or invalid email or password or otherwise incorrect auth data. */
static readonly UNAUTHORIZED = 'UNAUTHORIZED';

/** Provided form data is invalid, e.g. email is too long or captcha is expired. */
static readonly INVALID_FORM = 'INVALID_FORM';

/** Provided email is already taken. Applies to customer registration only. */
static readonly UNAVAILABLE = 'UNAVAILABLE';

/** Any other or internal error that interrupted authentication. */
static readonly UNKNOWN = 'UNKNOWN';

Expand All @@ -39,6 +47,8 @@ export class AuthError extends Error {
v8n().exact(AuthError.NEW_PASSWORD_REQUIRED),
v8n().exact(AuthError.INVALID_NEW_PASSWORD),
v8n().exact(AuthError.UNAUTHORIZED),
v8n().exact(AuthError.INVALID_FORM),
v8n().exact(AuthError.UNAVAILABLE),
v8n().exact(AuthError.UNKNOWN)
),
}),
Expand Down
43 changes: 39 additions & 4 deletions src/customer/API.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as Core from '../core/index.js';
import type { Credentials, Session, SignUpParams, StoredSession } from './types';
import type { Graph } from './Graph';

import type { Credentials, Session, StoredSession } from './types';
import { Request, fetch } from 'cross-fetch';

import type { Graph } from './Graph';
import { v8n } from '../core/v8n.js';

import * as Core from '../core/index.js';

/**
* Customer API for adding custom functionality to websites and web apps with our Customer Portal.
*
Expand All @@ -19,6 +19,16 @@ export class API extends Core.API<Graph> {

/** Validators for the method arguments in this class (internal). */
static readonly v8n = Object.assign({}, Core.API.v8n, {
signUpParams: v8n().schema({
verification: v8n().schema({
token: v8n().string(),
type: v8n().passesAnyOf(v8n().exact('hcaptcha')),
}),
first_name: v8n().optional(v8n().string().maxLength(50)),
last_name: v8n().optional(v8n().string().maxLength(50)),
password: v8n().string().maxLength(50),
email: v8n().string().maxLength(100),
}),
credentials: v8n().schema({
email: v8n().string(),
newPassword: v8n().optional(v8n().string()),
Expand Down Expand Up @@ -55,6 +65,31 @@ export class API extends Core.API<Graph> {
}
}

/**
* Creates a new customer account with the given credentials.
* If the email is already taken, `Core.API.AuthError` with code `UNAVAILABLE` will be thrown.
* If customer registration is disabled, `Core.API.AuthError` with code `UNAUTHORIZED` will be thrown.
* If the provided form data is invalid (e.g. captcha is expired), `Core.API.AuthError` with code `INVALID_FORM` will be thrown.
*
* @param params Customer information.
*/
async signUp(params: SignUpParams): Promise<void> {
API.v8n.signUpParams.check(params);

const url = new URL('./sign_up', this.base);
const response = await this.fetch(url.toString(), {
method: 'POST',
body: JSON.stringify(params),
});

if (!response.ok) {
if (response.status === 400) throw new Core.API.AuthError({ code: 'INVALID_FORM' });
if (response.status === 401) throw new Core.API.AuthError({ code: 'UNAUTHORIZED' });
if (response.status === 403) throw new Core.API.AuthError({ code: 'UNAVAILABLE' });
throw new Core.API.AuthError({ code: 'UNKNOWN' });
}
}

/**
* Initiates password reset for a customer with the given email.
* If such customer exists, they will receive an email from Foxy with further instructions.
Expand Down
23 changes: 23 additions & 0 deletions src/customer/Graph/customer_portal_settings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ export interface CustomerPortalSettings extends Graph {
sso: boolean;
/** Life span of session in minutes. Maximum 40320 (4 weeks). */
session_lifespan_in_minutes: number;
/** Determines if a terms of service checkbox is shown on the portal. This value comes from a template config linked to the default template set. */
tos_checkbox_settings: {
/** Initial state of the checkbox element. */
initial_state: 'unchecked' | 'checked';
/** Hides the checkbox if true. */
is_hidden: boolean;
/** Hides the checkbox if "none". Makes accepting ToS mandatory if "required", and optional otherwise. */
usage: 'none' | 'optional' | 'required';
/** Public URL of your terms of service agreement. */
url: string;
};
/** Self-registration settings. Self-registration is disabled if this field is undefined. */
sign_up?: {
/** Client verification settings. */
verification: {
/** hCaptcha site key. If empty, Foxy will use its own hCaptcha site key. */
site_key: string;
/** Verification type. Currently only hCaptcha is supported. */
type: 'hcaptcha';
};
/** If this field is true, then self-registration is enabled. */
enabled: boolean;
};
/** The date this resource was created. */
date_created: string | null;
/** The date this resource was last modified. */
Expand Down
19 changes: 19 additions & 0 deletions src/customer/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ export interface Credentials {
password: string;
}

/** Account creation parameters. */
export interface SignUpParams {
/** Signup verification (currently only hCaptcha is supported). */
verification: {
/** Verification type. Currently only hCaptcha is supported. */
type: 'hcaptcha';
/** hCaptcha verification token. */
token: string;
};
/** Customer's first name, optional, up to 50 characters. */
first_name?: string;
/** Customer's last name, optional, up to 50 characters. */
last_name?: string;
/** Customer's password (up to 50 characters). If not provided, Foxy will generate a random password for this account server-side. */
password?: string;
/** Customer's email address (up to 100 characters), required. */
email: string;
}

export interface Session {
session_token: string;
expires_in: number;
Expand Down
2 changes: 2 additions & 0 deletions tests/core/API/AuthError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ describe('Core', () => {
expect(AuthError).toHaveProperty('NEW_PASSWORD_REQUIRED');
expect(AuthError).toHaveProperty('INVALID_NEW_PASSWORD');
expect(AuthError).toHaveProperty('UNAUTHORIZED');
expect(AuthError).toHaveProperty('INVALID_FORM');
expect(AuthError).toHaveProperty('UNAVAILABLE');
expect(AuthError).toHaveProperty('UNKNOWN');
});

Expand Down
93 changes: 92 additions & 1 deletion tests/customer/API.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jest.mock('cross-fetch', () => ({
import { Request, Response, fetch } from 'cross-fetch';

import { API as CoreAPI } from '../../src/core/API';
import { Credentials } from '../../src/customer/types';
import { Credentials, SignUpParams } from '../../src/customer/types';
import { API as CustomerAPI } from '../../src/customer/API';

const fetchMock = (fetch as unknown) as jest.MockInstance<unknown, unknown[]>;
Expand Down Expand Up @@ -184,5 +184,96 @@ describe('Customer', () => {
new CoreAPI.AuthError({ code: 'UNKNOWN' })
);
});

it('can register a customer with valid parameters', async () => {
const params: SignUpParams = {
verification: { type: 'hcaptcha', token: 'abc123' },
password: 'password123',
email: 'test@example.com',
};

const expectedUrl = new URL('./sign_up', commonInit.base).toString();
const expectedBody = JSON.stringify(params);

fetchMock.mockImplementationOnce(async (url, options) => {
const request = new Request(url as RequestInfo, options as RequestInit);
expect(request.url).toBe(expectedUrl);
expect(request.method).toBe('POST');
expect(await request.text()).toBe(expectedBody);
return new Response(JSON.stringify({ success: true }), { status: 200 });
});

await new CustomerAPI(commonInit).signUp(params);

expect(fetchMock).toHaveBeenCalledWith(
new Request(expectedUrl, {
headers: commonHeaders,
method: 'POST',
body: expectedBody,
})
);

fetchMock.mockClear();
});

it('throws an error with code UNAVAILABLE if the email is already taken', async () => {
const params: SignUpParams = {
verification: { type: 'hcaptcha', token: 'abc123' },
password: 'password123',
email: 'test@example.com',
};

fetchMock.mockImplementationOnce(() => Promise.resolve(new Response(null, { status: 403 })));

const api = new CustomerAPI(commonInit);
await expect(api.signUp(params)).rejects.toThrow(new CoreAPI.AuthError({ code: 'UNAVAILABLE' }));

fetchMock.mockClear();
});

it('throws an error with code UNAUTHORIZED if customer registration is disabled', async () => {
const params: SignUpParams = {
verification: { type: 'hcaptcha', token: 'abc123' },
password: 'password123',
email: 'test@example.com',
};

fetchMock.mockImplementationOnce(() => Promise.resolve(new Response(null, { status: 401 })));

const api = new CustomerAPI(commonInit);
await expect(api.signUp(params)).rejects.toThrow(new CoreAPI.AuthError({ code: 'UNAUTHORIZED' }));

fetchMock.mockClear();
});

it('throws an error with code INVALID_FORM if captcha is expired', async () => {
const params: SignUpParams = {
verification: { type: 'hcaptcha', token: 'abc123' },
password: 'password123',
email: 'test@example.com',
};

fetchMock.mockImplementationOnce(() => Promise.resolve(new Response(null, { status: 400 })));

const api = new CustomerAPI(commonInit);
await expect(api.signUp(params)).rejects.toThrow(new CoreAPI.AuthError({ code: 'INVALID_FORM' }));

fetchMock.mockClear();
});

it('throws an error with code UNKNOWN when sign up request fails with an unknown error', async () => {
const params: SignUpParams = {
verification: { type: 'hcaptcha', token: 'abc123' },
password: 'password123',
email: 'test@example.com',
};

fetchMock.mockImplementationOnce(() => Promise.resolve(new Response(null, { status: 500 })));

const api = new CustomerAPI(commonInit);
await expect(api.signUp(params)).rejects.toThrow(new CoreAPI.AuthError({ code: 'UNKNOWN' }));

fetchMock.mockClear();
});
});
});

0 comments on commit ea24467

Please sign in to comment.