feat: add configuration to disable user registration

This commit is contained in:
Matthew H. Irby
2023-08-07 11:43:15 -04:00
committed by GitHub
parent 54941f9dcf
commit fe034c1fcc
28 changed files with 353 additions and 4 deletions

View File

@ -55,3 +55,31 @@ jobs:
if: always() if: always()
working-directory: ./deploy/docker-compose working-directory: ./deploy/docker-compose
run: docker compose -f "quickstart.yaml" down run: docker compose -f "quickstart.yaml" down
nosignup:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Copy config
working-directory: ./deploy/docker-compose
run: cp config-disable-signup.yaml config.yaml
- name: Start containers
working-directory: ./deploy/docker-compose
run: docker compose -f "quickstart.yaml" up -d --build
- name: Install dependencies
working-directory: ./e2e
run: |
npm install
npx playwright install chromium
- name: Run tests
working-directory: ./e2e
run: npm run 'test:nosignup'
- name: Stop containers
if: always()
working-directory: ./deploy/docker-compose
run: docker compose -f "quickstart.yaml" down

View File

@ -140,6 +140,10 @@ func DefaultConfig() *Config {
Interval: 1 * time.Minute, Interval: 1 * time.Minute,
}, },
}, },
Account: Account{
AllowDeletion: false,
AllowSignup: true,
},
} }
} }
@ -626,4 +630,5 @@ type LoggerConfig struct {
type Account struct { type Account struct {
// Allow Deletion indicates if a user can perform self-service deletion // Allow Deletion indicates if a user can perform self-service deletion
AllowDeletion bool `yaml:"allow_deletion" json:"allow_deletion,omitempty" koanf:"allow_deletion" jsonschema:"default=false"` AllowDeletion bool `yaml:"allow_deletion" json:"allow_deletion,omitempty" koanf:"allow_deletion" jsonschema:"default=false"`
AllowSignup bool `yaml:"allow_signup" json:"allow_signup,omitempty" koanf:"allow_signup" jsonschema:"default=true"`
} }

View File

@ -15,6 +15,12 @@ func TestDefaultConfigNotEnoughForValidation(t *testing.T) {
} }
} }
func TestDefaultConfigAccountParameters(t *testing.T) {
cfg := DefaultConfig()
assert.Equal(t, cfg.Account.AllowDeletion, false)
assert.Equal(t, cfg.Account.AllowSignup, true)
}
func TestParseValidConfig(t *testing.T) { func TestParseValidConfig(t *testing.T) {
configPath := "./config.yaml" configPath := "./config.yaml"
cfg, err := Load(&configPath) cfg, err := Load(&configPath)

View File

@ -579,4 +579,11 @@ account:
# Default: false # Default: false
# #
allow_deletion: false allow_deletion: false
## allow_signup
#
# Users are able to sign up new accounts.
#
# Default: true
#
allow_signup: true
``` ```

View File

@ -38,6 +38,10 @@ type UserCreateBody struct {
} }
func (h *UserHandler) Create(c echo.Context) error { func (h *UserHandler) Create(c echo.Context) error {
if !h.cfg.Account.AllowSignup {
return echo.NewHTTPError(http.StatusForbidden).SetInternal(errors.New("account signup is disabled"))
}
var body UserCreateBody var body UserCreateBody
if err := (&echo.DefaultBinder{}).BindBody(c, &body); err != nil { if err := (&echo.DefaultBinder{}).BindBody(c, &body); err != nil {
return dto.ToHttpError(err) return dto.ToHttpError(err)

View File

@ -220,6 +220,27 @@ func (s *userSuite) TestUserHandler_Create_EmailMissing() {
s.Equal(http.StatusBadRequest, rec.Code) s.Equal(http.StatusBadRequest, rec.Code)
} }
func (s *userSuite) TestUserHandler_Create_AccountCreationDisabled() {
if testing.Short() {
s.T().Skip("skipping test in short mode.")
}
testConfig := test.DefaultConfig
testConfig.Account.AllowSignup = false
e := NewPublicRouter(&testConfig, s.Storage, nil)
body := UserCreateBody{Email: "jane.doe@example.com"}
bodyJson, err := json.Marshal(body)
s.NoError(err)
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(bodyJson))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
s.Equal(http.StatusForbidden, rec.Code)
}
func (s *userSuite) TestUserHandler_Get() { func (s *userSuite) TestUserHandler_Get() {
if testing.Short() { if testing.Short() {
s.T().Skip("skipping test in short mode.") s.T().Skip("skipping test in short mode.")

View File

@ -9,6 +9,11 @@
"type": "boolean", "type": "boolean",
"description": "Allow Deletion indicates if a user can perform self-service deletion", "description": "Allow Deletion indicates if a user can perform self-service deletion",
"default": false "default": false
},
"allow_signup": {
"type": "boolean",
"description": "Allow Signup indicates if a user can sign up with service",
"default": true
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@ -568,4 +573,4 @@
"description": "WebauthnSettings defines the settings for the webauthn authentication mechanism" "description": "WebauthnSettings defines the settings for the webauthn authentication mechanism"
} }
} }
} }

View File

@ -36,4 +36,8 @@ var DefaultConfig = config.Config{
Service: config.Service{ Service: config.Service{
Name: "Test", Name: "Test",
}, },
Account: config.Account{
AllowSignup: true,
AllowDeletion: false,
},
} }

View File

@ -0,0 +1,31 @@
database:
user: hanko
password: hanko
host: postgresd
port: 5432
dialect: postgres
passcode:
email:
from_address: no-reply@hanko.io
smtp:
host: "mailslurper"
port: "2500"
secrets:
keys:
- abcedfghijklmnopqrstuvwxyz
service:
name: Hanko Authentication Service
webauthn:
relying_party:
origins:
- "http://localhost:8888"
session:
cookie:
secure: false # is needed for safari, because safari does not store secure cookies on localhost
server:
public:
cors:
allow_origins:
- "http://localhost:8888"
account:
allow_signup: false

View File

@ -125,6 +125,7 @@
<tr class="deep-level-0"> <tr class="deep-level-0">
<td class="name"><code>allow_deletion</code></td> <td class="name"><code>allow_deletion</code></td>
<td class="name"><code>allow_signup</code></td>
<td class="type"> <td class="type">
@ -244,4 +245,4 @@
</body> </body>
</html> </html>

View File

@ -819,6 +819,7 @@ paths:
/users: /users:
post: post:
summary: 'Create a user' summary: 'Create a user'
description: Used to create a new user. To disable this endpoint, `config.account.allow_signup` must be set to false.
operationId: createUser operationId: createUser
tags: tags:
- User Management - User Management
@ -842,6 +843,8 @@ paths:
$ref: '#/components/schemas/CreateUserResponse' $ref: '#/components/schemas/CreateUserResponse'
'400': '400':
$ref: '#/components/responses/BadRequest' $ref: '#/components/responses/BadRequest'
'403':
$ref: '#/components/responses/Forbidden'
'409': '409':
$ref: '#/components/responses/Conflict' $ref: '#/components/responses/Conflict'
'500': '500':
@ -1097,6 +1100,9 @@ components:
allow_deletion: allow_deletion:
description: Indicates the user account can be deleted by the current user. description: Indicates the user account can be deleted by the current user.
type: boolean type: boolean
allow_signup:
description: Indicates users are able to create new accounts.
type: boolean
CookieSession: CookieSession:
type: string type: string
description: Value `<JWT>` is a [JSON Web Token](https://www.rfc-editor.org/rfc/rfc7519.html) description: Value `<JWT>` is a [JSON Web Token](https://www.rfc-editor.org/rfc/rfc7519.html)

View File

@ -6,6 +6,8 @@ import { RegisterAuthenticator } from "../pages/RegisterAuthenticator.js";
import { SecuredContent } from "../pages/SecuredContent.js"; import { SecuredContent } from "../pages/SecuredContent.js";
import { LoginPassword } from "../pages/LoginPassword.js"; import { LoginPassword } from "../pages/LoginPassword.js";
import { RegisterPassword } from "../pages/RegisterPassword.js"; import { RegisterPassword } from "../pages/RegisterPassword.js";
import { LoginEmailNoSignup } from "../pages/LoginEmailNoSignUp.js";
import { NoAccountFound } from "../pages/NoAccountFound.js";
import { MailSlurper } from "../helper/MailSlurper.js"; import { MailSlurper } from "../helper/MailSlurper.js";
import * as Matchers from "../helper/Matchers.js"; import * as Matchers from "../helper/Matchers.js";
import { Error } from "../pages/Error.js"; import { Error } from "../pages/Error.js";
@ -15,6 +17,8 @@ import Setup from "../helper/Setup.js";
export type Pages = { export type Pages = {
errorPage: Error; errorPage: Error;
loginEmailPage: LoginEmail; loginEmailPage: LoginEmail;
loginEmailNoSignupPage: LoginEmailNoSignup;
noAccountFoundPage: NoAccountFound,
registerConfirmPage: RegisterConfirm; registerConfirmPage: RegisterConfirm;
loginPasscodePage: LoginPasscode; loginPasscodePage: LoginPasscode;
loginPasswordPage: LoginPassword; loginPasswordPage: LoginPassword;
@ -59,6 +63,19 @@ export const test = base.extend<TestOptions & Pages>({
await use(loginEmailPage); await use(loginEmailPage);
}, },
loginEmailNoSignupPage: async ({ baseURL, page }, use) => {
await Promise.all([
page.waitForResponse(Endpoints.API.WELL_KNOWN_CONFIG),
page.goto(baseURL!),
]);
const loginEmailPageNoSignup: LoginEmailNoSignup = new LoginEmailNoSignup(page);
await use(loginEmailPageNoSignup);
},
noAccountFoundPage: async ({ page }, use) => {
await use(new NoAccountFound(page));
},
registerConfirmPage: async ({ page }, use) => { registerConfirmPage: async ({ page }, use) => {
await use(new RegisterConfirm(page)); await use(new RegisterConfirm(page));
}, },

View File

@ -8,6 +8,7 @@
"scripts": { "scripts": {
"test:nopw": "npx playwright test passwordless common", "test:nopw": "npx playwright test passwordless common",
"test:pw": "npx playwright test passwords common", "test:pw": "npx playwright test passwords common",
"test:nosignup": "npx playwright test nosignup",
"test:report": "npx playwright show-report" "test:report": "npx playwright show-report"
}, },
"repository": { "repository": {

View File

@ -0,0 +1,38 @@
import type { Locator, Page } from "@playwright/test";
import { BasePage } from "./BasePage.js";
import Endpoints from "../helper/Endpoints.js";
export class LoginEmailNoSignup extends BasePage {
readonly emailInput: Locator;
readonly continueButton: Locator;
readonly signInPasskeyButton: Locator;
readonly headline: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.locator("input[name=email]");
this.continueButton = page.locator("button[type=submit]", {
hasText: "Continue",
});
this.signInPasskeyButton = page.locator("button[type=submit]", {
hasText: "Sign in with a passkey",
});
this.headline = page.locator("h1", { hasText: "Sign in" });
}
async continueUsingEmail(email: string) {
await this.emailInput.fill(email);
await Promise.all([
this.page.waitForResponse(Endpoints.API.USER),
this.continueButton.click(),
]);
}
async signInWithPasskey() {
await Promise.all([
this.page.waitForResponse(Endpoints.API.WEBAUTHN_LOGIN_INITIALIZE),
this.page.waitForResponse(Endpoints.API.WEBAUTHN_LOGIN_FINALIZE),
this.signInPasskeyButton.click(),
]);
}
}

View File

@ -0,0 +1,31 @@
import type { Locator, Page } from "@playwright/test";
import { BasePage } from "./BasePage.js";
import { expect } from "../fixtures/Pages.js";
export class NoAccountFound extends BasePage {
readonly backLink: Locator;
readonly signUpButton: Locator;
readonly headline: Locator;
constructor(page: Page) {
super(page);
this.backLink = page.locator("button", { hasText: "Back" });
this.signUpButton = page.locator("button[type=submit]", {
hasText: "Sign up",
});
this.headline = page.locator("h1", { hasText: "No account found" });
}
async assertSignupButtonNotVisible() {
await expect(this.signUpButton).not.toBeVisible();
}
async assertNoAccountFoundText(email: string) {
const text = this.page.locator("p", {hasText: `No account exists for "${email}".`});
await expect(text).toBeVisible();
}
async back() {
await this.backLink.click();
}
}

View File

@ -0,0 +1,34 @@
import { test, expect } from "../fixtures/Pages.js";
import { faker } from "@faker-js/faker";
test.describe("@nosignup", () => {
test("Login with account not found", async ({
loginEmailNoSignupPage,
noAccountFoundPage
}) => {
const email = faker.internet.email();
await test.step("When I visit the baseURL, the LoginEmailNoSignup page should be shown", async () => {
await expect(loginEmailNoSignupPage.headline).toBeVisible();
await expect(loginEmailNoSignupPage.signInPasskeyButton).toBeVisible();
});
await test.step("And when I submit an email address", async () => {
await loginEmailNoSignupPage.continueUsingEmail(email);
});
await test.step("No account should be found", async () => {
await noAccountFoundPage.assertNoAccountFoundText(email);
});
await test.step("Signup button should not be visible", async() => {
await noAccountFoundPage.assertSignupButtonNotVisible();
});
await test.step("Navigating back should take me back to LoginEmailNoSignup page", async () => {
await noAccountFoundPage.back();
await expect(loginEmailNoSignupPage.headline).toBeVisible();
await expect(loginEmailNoSignupPage.signInPasskeyButton).toBeVisible();
});
});
});

View File

@ -4,6 +4,7 @@ export const de: Translation = {
headlines: { headlines: {
error: "Ein Fehler ist aufgetreten", error: "Ein Fehler ist aufgetreten",
loginEmail: "Anmelden / Registrieren", loginEmail: "Anmelden / Registrieren",
loginEmailNoSignup: "Anmelden",
loginFinished: "Login erfolgreich", loginFinished: "Login erfolgreich",
loginPasscode: "Passcode eingeben", loginPasscode: "Passcode eingeben",
loginPassword: "Passwort eingeben", loginPassword: "Passwort eingeben",
@ -24,6 +25,7 @@ export const de: Translation = {
createdAt: "Erstellt am", createdAt: "Erstellt am",
connectedAccounts: "Verbundene Konten", connectedAccounts: "Verbundene Konten",
deleteAccount: "Konto löschen", deleteAccount: "Konto löschen",
accountNotFound: "Konto nicht gefunden"
}, },
texts: { texts: {
enterPasscode: enterPasscode:
@ -57,6 +59,8 @@ export const de: Translation = {
"Löschen Sie diesen Passkey aus Ihrem Konto. Beachten Sie, dass der Passkey noch auf Ihren Geräten vorhanden ist und auch dort gelöscht werden muss.", "Löschen Sie diesen Passkey aus Ihrem Konto. Beachten Sie, dass der Passkey noch auf Ihren Geräten vorhanden ist und auch dort gelöscht werden muss.",
deleteAccount: deleteAccount:
"Sind Sie sicher, dass Sie Ihr Konto löschen wollen? Alle Daten werden sofort gelöscht und können nicht wiederhergestellt werden.", "Sind Sie sicher, dass Sie Ihr Konto löschen wollen? Alle Daten werden sofort gelöscht und können nicht wiederhergestellt werden.",
noAccountExists:
'Es existiert kein Konto für "{emailAddress}".',
}, },
labels: { labels: {
or: "oder", or: "oder",

View File

@ -4,6 +4,7 @@ export const en: Translation = {
headlines: { headlines: {
error: "An error has occurred", error: "An error has occurred",
loginEmail: "Sign in or sign up", loginEmail: "Sign in or sign up",
loginEmailNoSignup: "Sign in",
loginFinished: "Login successful", loginFinished: "Login successful",
loginPasscode: "Enter passcode", loginPasscode: "Enter passcode",
loginPassword: "Enter password", loginPassword: "Enter password",
@ -24,6 +25,7 @@ export const en: Translation = {
createdAt: "Created at", createdAt: "Created at",
connectedAccounts: "Connected accounts", connectedAccounts: "Connected accounts",
deleteAccount: "Delete account", deleteAccount: "Delete account",
accountNotFound: "Account not found",
}, },
texts: { texts: {
enterPasscode: 'Enter the passcode that was sent to "{emailAddress}".', enterPasscode: 'Enter the passcode that was sent to "{emailAddress}".',
@ -55,6 +57,8 @@ export const en: Translation = {
"Delete this passkey from your account. Note that the passkey will still exist on your devices and needs to be deleted there as well.", "Delete this passkey from your account. Note that the passkey will still exist on your devices and needs to be deleted there as well.",
deleteAccount: deleteAccount:
"Are you sure you want to delete this account? All data will be deleted immediately and cannot be recovered.", "Are you sure you want to delete this account? All data will be deleted immediately and cannot be recovered.",
noAccountExists:
'No account exists for "{emailAddress}".',
}, },
labels: { labels: {
or: "or", or: "or",

View File

@ -4,6 +4,7 @@ export const fr: Translation = {
headlines: { headlines: {
error: "Une erreur s'est produite", error: "Une erreur s'est produite",
loginEmail: "Se connecter ou s'inscrire", loginEmail: "Se connecter ou s'inscrire",
loginEmailNoSignup: "Se connecter",
loginFinished: "Connexion réussie", loginFinished: "Connexion réussie",
loginPasscode: "Entrez le code d'accès", loginPasscode: "Entrez le code d'accès",
loginPassword: "Entrez le mot de passe", loginPassword: "Entrez le mot de passe",
@ -24,6 +25,7 @@ export const fr: Translation = {
createdAt: "Créé le", createdAt: "Créé le",
connectedAccounts: "Comptes connectés", connectedAccounts: "Comptes connectés",
deleteAccount: "Supprimer le compte", deleteAccount: "Supprimer le compte",
accountNotFound: "Compte non trouvé",
}, },
texts: { texts: {
enterPasscode: enterPasscode:
@ -57,6 +59,8 @@ export const fr: Translation = {
"Supprimez cette clé d'identification de votre compte. Notez que la clé d'identification continuera d'exister sur vos appareils et devra également y être supprimée.", "Supprimez cette clé d'identification de votre compte. Notez que la clé d'identification continuera d'exister sur vos appareils et devra également y être supprimée.",
deleteAccount: deleteAccount:
"Êtes-vous sûr de vouloir supprimer ce compte ? Toutes les données seront supprimées immédiatement et ne pourront pas être récupérées.", "Êtes-vous sûr de vouloir supprimer ce compte ? Toutes les données seront supprimées immédiatement et ne pourront pas être récupérées.",
noAccountExists:
'Aucun compte n\'existe pour "{emailAddress}".',
}, },
labels: { labels: {
or: "ou", or: "ou",

View File

@ -7,7 +7,9 @@ export interface Translations {
export interface Translation { export interface Translation {
headlines: { headlines: {
error: string; error: string;
accountNotFound: string;
loginEmail: string; loginEmail: string;
loginEmailNoSignup: string;
loginFinished: string; loginFinished: string;
loginPasscode: string; loginPasscode: string;
loginPassword: string; loginPassword: string;
@ -33,6 +35,7 @@ export interface Translation {
enterPasscode: string; enterPasscode: string;
setupPasskey: string; setupPasskey: string;
createAccount: string; createAccount: string;
noAccountExists: string;
passwordFormatHint: string; passwordFormatHint: string;
manageEmails: string; manageEmails: string;
changePassword: string; changePassword: string;

View File

@ -0,0 +1,45 @@
import { Fragment } from "preact";
import { useContext } from "preact/compat";
import { TranslateContext } from "@denysvuika/preact-translate";
import Content from "../components/wrapper/Content";
import Footer from "../components/wrapper/Footer";
import ErrorMessage from "../components/error/ErrorMessage";
import Paragraph from "../components/paragraph/Paragraph";
import Headline1 from "../components/headline/Headline1";
import Link from "../components/link/Link";
interface Props {
emailAddress: string;
onBack: () => void;
}
const AccountNotFoundPage = ({
emailAddress,
onBack,
}: Props) => {
const { t } = useContext(TranslateContext);
const onBackClick = (event: Event) => {
event.preventDefault();
onBack();
};
return (
<Fragment>
<Content>
<Headline1>{t("headlines.accountNotFound")}</Headline1>
<Paragraph>{t("texts.noAccountExists", { emailAddress })}</Paragraph>
</Content>
<Footer>
<span hidden />
<Link onClick={onBackClick}>
{t("labels.back")}
</Link>
</Footer>
</Fragment>
);
};
export default AccountNotFoundPage;

View File

@ -37,6 +37,7 @@ import LoginPasswordPage from "./LoginPasswordPage";
import RegisterPasskeyPage from "./RegisterPasskeyPage"; import RegisterPasskeyPage from "./RegisterPasskeyPage";
import RegisterPasswordPage from "./RegisterPasswordPage"; import RegisterPasswordPage from "./RegisterPasswordPage";
import ErrorPage from "./ErrorPage"; import ErrorPage from "./ErrorPage";
import AccountNotFoundPage from "./AccountNotFoundPage";
interface Props { interface Props {
emailAddress?: string; emailAddress?: string;
@ -199,6 +200,15 @@ const LoginEmailPage = (props: Props) => {
] ]
); );
const renderAccountNotFound = useCallback(
() => setPage(<AccountNotFoundPage emailAddress={emailAddress} onBack={onBackHandler}/>),
[
emailAddress,
onBackHandler,
setPage
]
);
const loginWithEmailAndWebAuthn = () => { const loginWithEmailAndWebAuthn = () => {
let _userInfo: UserInfo; let _userInfo: UserInfo;
let _webauthnFinalizedResponse: WebauthnFinalized; let _webauthnFinalizedResponse: WebauthnFinalized;
@ -237,7 +247,13 @@ const LoginEmailPage = (props: Props) => {
}) })
.catch((e) => { .catch((e) => {
if (e instanceof NotFoundError) { if (e instanceof NotFoundError) {
renderRegistrationConfirm();
if (config.account.allow_signup) {
renderRegistrationConfirm();
return;
}
renderAccountNotFound();
return; return;
} }
@ -397,7 +413,7 @@ const LoginEmailPage = (props: Props) => {
return ( return (
<Content> <Content>
<Headline1>{t("headlines.loginEmail")}</Headline1> <Headline1>{config.account.allow_signup ? t("headlines.loginEmail") : t("headlines.loginEmailNoSignup")}</Headline1>
<ErrorMessage error={error} /> <ErrorMessage error={error} />
<Form onSubmit={onEmailSubmit}> <Form onSubmit={onEmailSubmit}>
<Input <Input

1
frontend/frontend-sdk/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
coverage

View File

@ -83,6 +83,7 @@ export type {
import { import {
HankoError, HankoError,
ConflictError, ConflictError,
ForbiddenError,
EmailAddressAlreadyExistsError, EmailAddressAlreadyExistsError,
InvalidPasswordError, InvalidPasswordError,
InvalidPasscodeError, InvalidPasscodeError,
@ -103,6 +104,7 @@ import {
export { export {
HankoError, HankoError,
ConflictError, ConflictError,
ForbiddenError,
EmailAddressAlreadyExistsError, EmailAddressAlreadyExistsError,
InvalidPasswordError, InvalidPasswordError,
InvalidPasscodeError, InvalidPasscodeError,

View File

@ -29,9 +29,11 @@ export interface EmailConfig {
* @category SDK * @category SDK
* @subcategory DTO * @subcategory DTO
* @property {boolean} allow_deletion - Indicates the current user is allowed to delete the account. * @property {boolean} allow_deletion - Indicates the current user is allowed to delete the account.
* @property {boolean} allow_signup - Indicates the current user is allowed to sign up.
*/ */
export interface AccountConfig { export interface AccountConfig {
allow_deletion: boolean; allow_deletion: boolean;
allow_signup: boolean;
} }
/** /**

View File

@ -221,6 +221,21 @@ class UnauthorizedError extends HankoError {
} }
} }
/**
* A 'ForbiddenError' occurs when the user is not allowed to perform the requested action.
*
* @category SDK
* @subcategory Errors
* @extends {HankoError}
*/
class ForbiddenError extends HankoError {
// eslint-disable-next-line require-jsdoc
constructor(cause?: Error) {
super("Forbidden error", "forbidden", cause);
Object.setPrototypeOf(this, ForbiddenError.prototype);
}
}
/** /**
* A 'UserVerificationError' occurs when the user verification requirements * A 'UserVerificationError' occurs when the user verification requirements
* for a WebAuthn ceremony are not met. * for a WebAuthn ceremony are not met.
@ -306,6 +321,7 @@ export {
NotFoundError, NotFoundError,
TooManyRequestsError, TooManyRequestsError,
UnauthorizedError, UnauthorizedError,
ForbiddenError,
UserVerificationError, UserVerificationError,
MaxNumOfEmailAddressesReachedError, MaxNumOfEmailAddressesReachedError,
EmailAddressAlreadyExistsError, EmailAddressAlreadyExistsError,

View File

@ -4,6 +4,7 @@ import {
NotFoundError, NotFoundError,
TechnicalError, TechnicalError,
UnauthorizedError, UnauthorizedError,
ForbiddenError,
} from "../Errors"; } from "../Errors";
import { Client } from "./Client"; import { Client } from "./Client";
@ -55,6 +56,8 @@ class UserClient extends Client {
if (response.status === 409) { if (response.status === 409) {
throw new ConflictError(); throw new ConflictError();
} if (response.status === 403) {
throw new ForbiddenError();
} else if (!response.ok) { } else if (!response.ok) {
throw new TechnicalError(); throw new TechnicalError();
} }

View File

@ -2,6 +2,7 @@ import {
ConflictError, ConflictError,
NotFoundError, NotFoundError,
TechnicalError, TechnicalError,
ForbiddenError,
UserClient, UserClient,
} from "../../../src"; } from "../../../src";
import { Response } from "../../../src/lib/client/HttpClient"; import { Response } from "../../../src/lib/client/HttpClient";
@ -179,6 +180,15 @@ describe("UserClient.create()", () => {
await expect(user).rejects.toThrow(ConflictError); await expect(user).rejects.toThrow(ConflictError);
}); });
it("should throw error when signup is disabled", async () => {
const response = new Response(new XMLHttpRequest());
response.status = 403;
jest.spyOn(userClient.client, "post").mockResolvedValue(response);
const user = userClient.create(email);
await expect(user).rejects.toThrow(ForbiddenError);
});
it("should throw error if API response is not ok (no 2xx, no 4xx)", async () => { it("should throw error if API response is not ok (no 2xx, no 4xx)", async () => {
const response = new Response(new XMLHttpRequest()); const response = new Response(new XMLHttpRequest());
jest.spyOn(userClient.client, "post").mockResolvedValue(response); jest.spyOn(userClient.client, "post").mockResolvedValue(response);