diff --git a/src/backend/Graph/customer_portal_settings.d.ts b/src/backend/Graph/customer_portal_settings.d.ts index 8084d96..840d1cf 100644 --- a/src/backend/Graph/customer_portal_settings.d.ts +++ b/src/backend/Graph/customer_portal_settings.d.ts @@ -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. */ diff --git a/src/core/API/AuthError.ts b/src/core/API/AuthError.ts index 2f7398c..927d70c 100644 --- a/src/core/API/AuthError.ts +++ b/src/core/API/AuthError.ts @@ -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']; /** @@ -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'; @@ -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) ), }), diff --git a/src/customer/API.ts b/src/customer/API.ts index 6f26a70..fc193d1 100644 --- a/src/customer/API.ts +++ b/src/customer/API.ts @@ -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. * @@ -19,6 +19,16 @@ export class API extends Core.API { /** 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()), @@ -55,6 +65,31 @@ export class API extends Core.API { } } + /** + * 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 { + 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. diff --git a/src/customer/Graph/customer_portal_settings.d.ts b/src/customer/Graph/customer_portal_settings.d.ts index 8cbf607..94b188f 100644 --- a/src/customer/Graph/customer_portal_settings.d.ts +++ b/src/customer/Graph/customer_portal_settings.d.ts @@ -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. */ diff --git a/src/customer/types.d.ts b/src/customer/types.d.ts index 02f6a25..309db9a 100644 --- a/src/customer/types.d.ts +++ b/src/customer/types.d.ts @@ -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; diff --git a/tests/core/API/AuthError.test.ts b/tests/core/API/AuthError.test.ts index beedc38..b6de1ae 100644 --- a/tests/core/API/AuthError.test.ts +++ b/tests/core/API/AuthError.test.ts @@ -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'); }); diff --git a/tests/customer/API.test.ts b/tests/customer/API.test.ts index 16d638c..8b01323 100644 --- a/tests/customer/API.test.ts +++ b/tests/customer/API.test.ts @@ -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; @@ -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(); + }); }); });