- import { WebauthnState } from "../state/WebauthnState";
+ import {
+ create as createWebauthnCredential,
+ get as getWebauthnCredential,
+} from "@github/webauthn-json";
+
+import { WebauthnSupport } from "../WebauthnSupport";
+import { Client } from "./Client";
+import { PasscodeState } from "../state/PasscodeState";
+
+import { WebauthnState } from "../state/WebauthnState";
+
import {
InvalidWebauthnCredentialError,
TechnicalError,
@@ -93,13 +103,13 @@ import {
WebauthnRequestCancelledError,
UserVerificationError,
} from "../Errors";
+
import {
- create as createWebauthnCredential,
- get as getWebauthnCredential,
-} from "@github/webauthn-json";
-import { Attestation, User, WebauthnFinalized } from "../Dto";
-import { WebauthnSupport } from "../WebauthnSupport";
-import { Client } from "./Client";
+ Attestation,
+ User,
+ WebauthnFinalized,
+ WebauthnCredentials,
+} from "../Dto";
/**
* A class that handles WebAuthn authentication and registration.
@@ -110,7 +120,8 @@ import { Client } from "./Client";
* @extends {Client}
*/
class WebauthnClient extends Client {
- state: WebauthnState;
+ webauthnState: WebauthnState;
+ passcodeState: PasscodeState;
controller: AbortController;
_getCredential = getWebauthnCredential;
@@ -123,7 +134,12 @@ class WebauthnClient extends Client {
* @public
* @type {WebauthnState}
*/
- this.state = new WebauthnState();
+ this.webauthnState = new WebauthnState();
+ /**
+ * @public
+ * @type {PasscodeState}
+ */
+ this.passcodeState = new PasscodeState();
}
/**
@@ -182,11 +198,13 @@ class WebauthnClient extends Client {
const finalizeResponse: WebauthnFinalized = assertionResponse.json();
- this.state
+ this.webauthnState
.read()
.addCredential(finalizeResponse.user_id, finalizeResponse.credential_id)
.write();
+ this.passcodeState.read().reset(userID).write();
+
return;
}
@@ -247,7 +265,7 @@ class WebauthnClient extends Client {
}
const finalizeResponse: WebauthnFinalized = attestationResponse.json();
- this.state
+ this.webauthnState
.read()
.addCredential(finalizeResponse.user_id, finalizeResponse.credential_id)
.write();
@@ -255,6 +273,81 @@ class WebauthnClient extends Client {
return;
}
+ /**
+ * Returns a list of all WebAuthn credentials assigned to the current user.
+ *
+ * @return {Promise<WebauthnCredentials>}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/listCredentials
+ */
+ async listCredentials(): Promise<WebauthnCredentials> {
+ const response = await this.client.get("/webauthn/credentials");
+
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
+ throw new TechnicalError();
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Updates the WebAuthn credential.
+ *
+ * @param {string=} credentialID - The credential's UUID.
+ * @param {string} name - The new credential name.
+ * @return {Promise<void>}
+ * @throws {NotFoundError}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/updateCredential
+ */
+ async updateCredential(credentialID: string, name: string): Promise<void> {
+ const response = await this.client.patch(
+ `/webauthn/credentials/${credentialID}`,
+ {
+ name,
+ }
+ );
+
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
+ throw new TechnicalError();
+ }
+
+ return;
+ }
+
+ /**
+ * Deletes the WebAuthn credential.
+ *
+ * @param {string=} credentialID - The credential's UUID.
+ * @return {Promise<void>}
+ * @throws {NotFoundError}
+ * @throws {UnauthorizedError}
+ * @throws {RequestTimeoutError}
+ * @throws {TechnicalError}
+ * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/deleteCredential
+ */
+ async deleteCredential(credentialID: string): Promise<void> {
+ const response = await this.client.delete(
+ `/webauthn/credentials/${credentialID}`
+ );
+
+ if (response.status === 401) {
+ throw new UnauthorizedError();
+ } else if (!response.ok) {
+ throw new TechnicalError();
+ }
+
+ return;
+ }
+
/**
* Determines whether a credential registration ceremony should be performed. Returns 'true' when WebAuthn
* is supported and the user's credentials do not intersect with the credentials already known on the
@@ -270,7 +363,7 @@ class WebauthnClient extends Client {
return supported;
}
- const matches = this.state
+ const matches = this.webauthnState
.read()
.matchCredentials(user.id, user.webauthn_credentials);
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasscodeState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasscodeState.ts.html
index 0f172469..2ec323a8 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasscodeState.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasscodeState.ts.html
@@ -68,7 +68,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
@@ -95,11 +95,13 @@ import { UserState } from "./UserState";
* @property {string=} id - The UUID of the active passcode.
* @property {number=} ttl - Timestamp until when the passcode is valid in seconds (since January 1, 1970 00:00:00 UTC).
* @property {number=} resendAfter - Seconds until a passcode can be resent.
+ * @property {emailID=} emailID - The email address ID.
*/
export interface LocalStoragePasscode {
id?: string;
ttl?: number;
resendAfter?: number;
+ emailID?: string;
}
/**
@@ -156,6 +158,29 @@ class PasscodeState extends UserState {
return this;
}
+ /**
+ * Gets the UUID of the email address.
+ *
+ * @param {string} userID - The UUID of the user.
+ * @return {string}
+ */
+ getEmailID(userID: string): string {
+ return this.getState(userID).emailID;
+ }
+
+ /**
+ * Sets the UUID of the email address.
+ *
+ * @param {string} userID - The UUID of the user.
+ * @param {string} emailID - The UUID of the email address.
+ * @return {PasscodeState}
+ */
+ setEmailID(userID: string, emailID: string): PasscodeState {
+ this.getState(userID).emailID = emailID;
+
+ return this;
+ }
+
/**
* Removes the active passcode.
*
@@ -168,6 +193,7 @@ class PasscodeState extends UserState {
delete passcode.id;
delete passcode.ttl;
delete passcode.resendAfter;
+ delete passcode.emailID;
return this;
}
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasswordState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasswordState.ts.html
index 145a0e57..a32145f7 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasswordState.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_PasswordState.ts.html
@@ -68,7 +68,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html
index ec38f817..d8ca9108 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html
@@ -68,7 +68,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_UserState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_UserState.ts.html
index 3dafebb4..f1fcbbf0 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_UserState.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_UserState.ts.html
@@ -68,7 +68,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_WebauthnState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_WebauthnState.ts.html
index fb89a8b8..7d88dfd4 100644
--- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_WebauthnState.ts.html
+++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_WebauthnState.ts.html
@@ -68,7 +68,7 @@
- SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
+ SDK Classes / Internal Classes / Clients Classes / Errors Classes / Utilities Interfaces / DTO Interfaces / Internal
diff --git a/docs/static/spec/admin.yaml b/docs/static/spec/admin.yaml
index b20c7e92..3ad9e964 100644
--- a/docs/static/spec/admin.yaml
+++ b/docs/static/spec/admin.yaml
@@ -50,43 +50,6 @@ paths:
'500':
$ref: '#/components/responses/InternalServerError'
/users/{id}:
- patch:
- summary: 'Update a user by ID'
- operationId: updateUser
- tags:
- - User Management
- parameters:
- - name: id
- in: path
- description: ID of the user
- required: true
- schema:
- $ref: '#/components/schemas/UUID4'
- requestBody:
- content:
- application/json:
- schema:
- type: object
- properties:
- email:
- type: string
- format: email
- status:
- type: string
- enum: [active, inactive]
- responses:
- '200':
- description: 'Updated user details'
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/User'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '500':
- $ref: '#/components/responses/InternalServerError'
delete:
summary: 'Delete a user by ID'
operationId: deleteUser
diff --git a/docs/static/spec/public.yaml b/docs/static/spec/public.yaml
index 71f01cc1..b3da9e06 100644
--- a/docs/static/spec/public.yaml
+++ b/docs/static/spec/public.yaml
@@ -1,7 +1,7 @@
openapi: 3.0.0
info:
- version: '0.3.0'
+ version: '0.4.0'
title: 'Hanko Public API'
description: |
## Introduction
@@ -60,7 +60,8 @@ paths:
summary: 'Initialize passcode login'
description: |
Initialize a passcode login for the user identified by `user_id`. Sends an email
- containing the actual passcode to the user. Returns a representation of the passcode.
+ containing the actual passcode to the user's primary email address or to the address specified
+ through `email_id`. Returns a representation of the passcode.
operationId: passcodeInit
tags:
- Passcode
@@ -74,6 +75,11 @@ paths:
description: The ID of the user
allOf:
- $ref: '#/components/schemas/UUID4'
+ email_id:
+ description: The ID of the email address
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ required: false
responses:
'200':
description: 'Successful passcode login initialization'
@@ -138,6 +144,8 @@ paths:
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
+ '403':
+ $ref: '#/components/responses/Forbidden'
'408':
$ref: '#/components/responses/RequestTimeOut'
'410':
@@ -409,6 +417,95 @@ paths:
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/InternalServerError'
+ /webauthn/credentials:
+ get:
+ summary: 'Get a list of WebAuthn credentials'
+ description: |
+ Returns a list of WebAuthn credentials assigned to the current user.
+ operationId: listCredentials
+ tags:
+ - WebAuthn
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ responses:
+ '200':
+ description: 'A list of WebAuthn credentials assigned to the current user'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WebauthnCredentials'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /webauthn/credentials/{id}:
+ patch:
+ summary: 'Updates a WebAuthn credential'
+ description: |
+ Updates the specified WebAuthn credential. Only credentials assigned to the current user can be updated.
+ operationId: updateCredential
+ tags:
+ - WebAuthn
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ parameters:
+ - name: id
+ in: path
+ description: ID of the WebAuthn credential
+ required: true
+ schema:
+ $ref: '#/components/schemas/UUID4'
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ description: "A new credential name. Has no technical meaning, only serves as an identification aid for the user."
+ type: string
+ required: false
+ responses:
+ '201':
+ description: 'Credential updated successfully'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ delete:
+ summary: 'Deletes a WebAuthn credential'
+ description: |
+ Deletes the specified WebAuthn credential.
+ operationId: deleteCredential
+ tags:
+ - WebAuthn
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ parameters:
+ - name: id
+ in: path
+ description: ID of the WebAuthn credential
+ required: true
+ schema:
+ $ref: '#/components/schemas/UUID4'
+ responses:
+ '201':
+ description: 'Credential updated successfully'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/NotFound'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
/.well-known/jwks.json:
get:
summary: 'Get JSON Web Key Set'
@@ -471,6 +568,8 @@ paths:
properties:
id:
$ref: '#/components/schemas/UUID4'
+ email_id:
+ $ref: '#/components/schemas/UUID4'
verified:
type: boolean
has_webauthn_credential:
@@ -532,7 +631,7 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/User'
+ $ref: '#/components/schemas/CreateUserResponse'
'400':
$ref: '#/components/responses/BadRequest'
'409':
@@ -567,6 +666,104 @@ paths:
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
+ /emails:
+ get:
+ summary: 'Get a list of emails of the current user.'
+ operationId: listEmails
+ tags:
+ - Email Management
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ responses:
+ '200':
+ description: 'A list of emails assigned to the current user'
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Emails'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ post:
+ summary: 'Add a new email address to the current user.'
+ operationId: createEmail
+ tags:
+ - Email Management
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ address:
+ type: string
+ format: email
+ required:
+ - address
+ responses:
+ '201':
+ description: 'Email successfully added'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '409':
+ $ref: '#/components/responses/Conflict'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /emails/{id}/set_primary:
+ post:
+ summary: 'Marks the email address as primary email'
+ operationId: setPrimaryEmail
+ tags:
+ - Email Management
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ parameters:
+ - name: id
+ in: path
+ description: ID of the email address
+ required: true
+ schema:
+ $ref: '#/components/schemas/UUID4'
+ responses:
+ '201':
+ description: 'Email has been set as primary'
+ '400':
+ $ref: '#/components/responses/BadRequest'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
+ /emails/{id}:
+ delete:
+ summary: 'Delete an email address'
+ operationId: deleteEmail
+ tags:
+ - Email Management
+ security:
+ - CookieAuth: [ ]
+ - BearerTokenAuth: [ ]
+ parameters:
+ - name: id
+ in: path
+ description: ID of the email address
+ required: true
+ schema:
+ $ref: '#/components/schemas/UUID4'
+ responses:
+ '201':
+ description: 'Email has been deleted'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '409':
+ $ref: '#/components/responses/Conflict'
+ '500':
+ $ref: '#/components/responses/InternalServerError'
components:
responses:
BadRequest:
@@ -658,6 +855,13 @@ components:
description: Hanko Configuration
url: https://github.com/teamhanko/hanko/blob/main/backend/docs/Config.md
properties:
+ emails:
+ description: Controls the behavior regarding email addresses.
+ type: object
+ properties:
+ require_verification:
+ description: Require email verification after account registration and prevent signing in with unverified email addresses. Also, email addresses can only be marked as primary when they have been verified before.
+ type: boolean
password:
description: Configuration options concerning passwords
type: object
@@ -950,7 +1154,7 @@ components:
- ble
- internal
example: internal
- User:
+ GetUserResponse:
type: object
properties:
id:
@@ -983,6 +1187,114 @@ components:
type: string
format: base64url
example: Meprtysj5ZZrTlg0qiLbsZ168OtQMeGVAikVy2n1hvvG...
+ CreateUserResponse:
+ type: object
+ properties:
+ user_id:
+ description: "The ID of the newly created user"
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ email_id:
+ description: "The ID of the newly created email address"
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ User:
+ type: object
+ properties:
+ id:
+ description: The ID of the user
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ email:
+ description: The email address of the user
+ type: string
+ format: email
+ created_at:
+ description: Time of creation of the the user
+ type: string
+ format: date-time
+ updated_at:
+ description: Time of last update of the user
+ type: string
+ format: date-time
+ webauthn_credentials:
+ description: List of registered Webauthn credentials
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ description: The ID of the Webauthn credential
+ type: string
+ format: base64url
+ example: Meprtysj5ZZrTlg0qiLbsZ168OtQMeGVAikVy2n1hvvG...
+ Emails:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ description: The ID of the email address
+ allOf:
+ - $ref: '#/components/schemas/UUID4'
+ address:
+ description: The email address
+ type: string
+ format: email
+ is_verified:
+ description: Indicated the email has been verified.
+ type: boolean
+ is_primary:
+ description: Indicates it's the primary email address.
+ type: boolean
+ example:
+ - id: 5333cc5b-c7c4-48cf-8248-9c184ac72b65
+ address: john.doe@example.com
+ is_verified: true
+ is_primary: false
+ WebauthnCredentials:
+ description: 'A list of WebAuthn credentials'
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ description: The ID of the Webauthn credential
+ type: string
+ format: base64url
+ example: Meprtysj5ZZrTlg0qiLbsZ168OtQMeGVAikVy2n1hvvG...
+ name:
+ description: The name of the credential. Can be updated by the user.
+ type: string
+ required: false
+ public_key:
+ description: The public key assigned to the credential.
+ type: boolean
+ aaguid:
+ description: The AAGUID of the authenticator.
+ type: boolean
+ transports:
+ description: Transports which may be used by the authenticator.
+ type: array
+ items:
+ type: string
+ enum:
+ - usb
+ - nfc
+ - ble
+ - internal
+ created_at:
+ description: Time of creation of the credential
+ type: string
+ format: date-time
+ example:
+ - id: 5333cc5b-c7c4-48cf-8248-9c184ac72b65
+ name: "iCloud"
+ public_key: "pQECYyagASFYIBblARCP_at3cmprjzQN1lJ..."
+ aaguid: "adce0002-35bc-c60a-648b-0b25f1f05503"
+ transports:
+ - internal
+ created_at: "022-12-06T21:26:06.535106Z"
WebauthnLoginResponse:
description: 'Response after a successful login with webauthn'
type: object
diff --git a/e2e/pages/LoginPasscode.ts b/e2e/pages/LoginPasscode.ts
index c1e3e758..5969a1f8 100644
--- a/e2e/pages/LoginPasscode.ts
+++ b/e2e/pages/LoginPasscode.ts
@@ -17,7 +17,7 @@ export class LoginPasscode extends BasePage {
this.signInButton = page.locator("button[type=submit]", {
hasText: "Sign in",
});
- this.sendNewCodeLink = page.locator("a", {
+ this.sendNewCodeLink = page.locator("button", {
hasText: "Send new code",
});
this.headline = page.locator("h1", { hasText: "Enter passcode" });
diff --git a/e2e/pages/LoginPassword.ts b/e2e/pages/LoginPassword.ts
index 1dae39d7..ee23dda2 100644
--- a/e2e/pages/LoginPassword.ts
+++ b/e2e/pages/LoginPassword.ts
@@ -15,8 +15,8 @@ export class LoginPassword extends BasePage {
this.signInButton = page.locator("button[type=submit]", {
hasText: "Sign in",
});
- this.backLink = page.locator("a", { hasText: "Back" });
- this.forgotPasswordLink = page.locator("a", {
+ this.backLink = page.locator("button", { hasText: "Back" });
+ this.forgotPasswordLink = page.locator("button", {
hasText: "Forgot your password?",
});
this.headline = page.locator("h1", { hasText: "Enter password" });
diff --git a/e2e/pages/RegisterAuthenticator.ts b/e2e/pages/RegisterAuthenticator.ts
index 18ab1962..0a297060 100644
--- a/e2e/pages/RegisterAuthenticator.ts
+++ b/e2e/pages/RegisterAuthenticator.ts
@@ -13,7 +13,7 @@ export class RegisterAuthenticator extends BasePage {
this.setUpPasskeyButton = page.locator("button[type=submit]", {
hasText: "Save a passkey",
});
- this.skipLink = page.locator("a", {
+ this.skipLink = page.locator("button", {
hasText: "Skip",
});
this.headline = page.locator("h1", { hasText: "Save a passkey" });
diff --git a/e2e/pages/RegisterConfirm.ts b/e2e/pages/RegisterConfirm.ts
index 363ac27d..63438335 100644
--- a/e2e/pages/RegisterConfirm.ts
+++ b/e2e/pages/RegisterConfirm.ts
@@ -10,7 +10,7 @@ export class RegisterConfirm extends BasePage {
constructor(page: Page) {
super(page);
- this.backLink = page.locator("a", { hasText: "Back" });
+ this.backLink = page.locator("button", { hasText: "Back" });
this.signUpButton = page.locator("button[type=submit]", {
hasText: "Sign up",
});
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index b6f51fc7..dc548c9f 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -23,7 +23,7 @@ COPY ./elements ./
RUN npm run build
FROM nginx:stable-alpine
-COPY --from=build /app/elements/dist/element.hanko-auth.js /usr/share/nginx/html
+COPY --from=build /app/elements/dist/elements.js /usr/share/nginx/html
COPY --from=build /app/frontend-sdk/dist/sdk.* /usr/share/nginx/html
COPY elements/nginx/default.conf /etc/nginx/conf.d/default.conf
diff --git a/frontend/elements/README.md b/frontend/elements/README.md
index b7839605..ee9442b9 100644
--- a/frontend/elements/README.md
+++ b/frontend/elements/README.md
@@ -1,6 +1,6 @@
-# <hanko-auth> element
+# Hanko elements
-The `` element offers a complete user interface that will bring a modern login and registration experience
+Provides web components that will bring a modern login and registration experience
to your users. It integrates the [Hanko API](https://github.com/teamhanko/hanko/blob/main/backend/README.md), a backend
that provides the underlying functionalities.
@@ -9,6 +9,7 @@ that provides the underlying functionalities.
* Registration and login flows with and without passwords
* Passkey authentication
* Passcodes, a convenient way to recover passwords and verify email addresses
+* Email, Password and Passkey management
* Customizable UI
## Installation
@@ -28,19 +29,14 @@ pnpm install @teamhanko/hanko-elements
### Script
-The web component needs to be registered first. You can control whether it should be attached to the shadow DOM or not
-using the `shadow` property. It's set to true by default, and you will be able to use the CSS parts
-to change the appearance of the component.
-
-There is currently an issue with Safari browsers, which breaks the autocompletion feature of
-input fields when the component is shadow DOM attached. So if you want to make use of the conditional UI or other
-autocompletion features you must set `shadow` to false. The disadvantage is that the CSS parts are not working anymore, and you must
-style the component by providing your own CSS properties. CSS variables will work in both cases.
+The web components need to be registered first. You can control whether they should be attached to the shadow DOM or not
+using the `shadow` property. It's set to true by default, and it's possible to make use of the [CSS shadow parts](#css-shadow-parts)
+to change the appearance of the component. [CSS variables](#css-variables) will work in both cases.
Use as a module:
```typescript
-import { register } from "@teamhanko/hanko-elements/hanko-auth"
+import { register } from "@teamhanko/hanko-elements"
register({
shadow: true, // Set to false if you don't want the web component to be attached to the shadow DOM.
@@ -51,31 +47,30 @@ register({
With a script tag via CDN:
```html
-
+
```
-### Markup
+### <hanko-auth>
+
+A web component that handles user login and user registration.
+
+#### Markup
```html
```
-Please take a look at the [Hanko API](https://github.com/teamhanko/hanko/blob/main/backend/README.md) to see how to spin up the backend.
-
-Note that we're working on Hanko Cloud, so that you don't need to run the Hanko API by yourself and all you need is to
-do is adding the `` element to your page.
-
-## Attributes
+#### Attributes
- `api` the location where the Hanko API is running.
- `lang` Currently supported values are "en" for English and "de" for German. If the value is omitted, "en" is used.
- `experimental` A space-seperated list of experimental features to be enabled. See [experimental features](#experimental-features).
-## Events
+#### Events
These events bubble up through the DOM tree.
@@ -87,66 +82,84 @@ document.addEventListener('hankoAuthSuccess', () => {
})
```
-## Demo
+### <hanko-profile>
-The animation below demonstrates how user registration with passwords enabled looks like. You can set up the flow you
-like using the [Hanko API](https://github.com/teamhanko/hanko/blob/main/backend/README.md) configuration file. The registration flow also includes email
-verification via passcodes and the registration of a passkey so that the user can log in without passwords or passcodes.
+A web component that allows to manage emails, passwords and passkeys.
-
+#### Markup
+
+```html
+
+```
+
+#### Attributes
+
+- `api` the location where the Hanko API is running.
+- `lang` Currently supported values are "en" for English and "de" for German. If the value is omitted, "en" is used.
## UI Customization
### CSS Variables
-CSS variables can be used to style the `hanko-auth` element to your needs. Based on preset values and provided CSS
-variables, individual elements will be styled, including color shading for different UI states (e.g. hover, focus,..).
-
-Note that colors must be provided as individual HSL values. We'll have to be patient, unfortunately, until
-broader browser support for relative colors arrives, which would allow native CSS colors to be used.
-
-A list of all CSS variables including default values can be found below:
+CSS variables can be used to style the `hanko-auth` and `hanko-profile` elements to your needs. A list of all CSS
+variables including default values can be found below:
```css
-hanko-auth {
- --background-color-h: 0;
- --background-color-s: 0%;
- --background-color-l: 100%;
+hanko-auth, hanko-profile {
+ /* Color Scheme */
+ --color: #171717
+ --color-shade-1: #8f9095
+ --color-shade-2: #e5e6ef
- --border-radius: 3px;
- --border-style: solid;
- --border-width: 1.5px;
+ --brand-color: #506cf0
+ --brand-color-shade-1: #6b84fb
+ --brand-contrast-color: white
- --brand-color-h: 351;
- --brand-color-s: 100%;
- --brand-color-l: 59%;
+ --background-color: white
+ --error-color: #e82020
+ --link-color: #506cf0
- --color-h: 0;
- --color-s: 0%;
- --color-l: 0%;
+ /* Font Styles */
+ --font-weight: 400
+ --font-size: 14px
+ --font-family: sans-serif
- --container-padding: 20px;
- --container-max-width: 600px;
+ /* Border Styles */
+ --border-radius: 4px
+ --border-style: solid
+ --border-width: 1px
- --error-color-h: 351;
- --error-color-s: 100%;
- --error-color-l: 59%;
+ /* Item Styles */
+ --item-height: 34px
+ --item-margin: .5rem 0
- --font-family: sans-serif;
- --font-size: 16px;
- --font-weight: 400;
+ /* Container Styles */
+ --container-padding: 0
+ --container-max-width: 600px
- --headline-font-size: 30px;
- --headline-font-weight: 700;
+ /* Headline Styles */
+ --headline1-font-size: 24px
+ --headline1-font-weight: 600
+ --headline1-margin: 0 0 .5rem
- --input-height: 50px;
+ --headline2-font-size: 14px
+ --headline2-font-weight: 600
+ --headline2-margin: 1rem 0 .25rem
- --item-margin: 15px 0;
+ /* Divider Styles */
+ --divider-padding: 0 42px
+ --divider-display: block
+ --divider-visibility: visible
- --lightness-adjust-dark: -30%;
- --lightness-adjust-dark-light: -10%;
- --lightness-adjust-light: 10%;
- --lightness-adjust-light-dark: 30%;
+ /* Link Styles */
+ --link-text-decoration: none
+ --link-text-decoration-hover: underline
+
+ /* Input Styles */
+ --input-min-width: 12em
+
+ /* Button Styles */
+ --button-min-width: max-content
}
```
@@ -215,66 +228,6 @@ autocompletion of input elements while the web component is attached to the shad
attach the component to the shadow DOM and make use of CSS parts for UI customization when the CSS variables are not
sufficient.
-### Example
-
-The example below shows how you can use CSS variables in combination with styled shadow DOM parts:
-
-```css
-hanko-auth {
- --color-h: 188;
- --color-s: 99%;
- --color-l: 38%;
-
- --brand-color-h: 315;
- --brand-color-s: 100%;
- --brand-color-l: 59%;
-
- --background-color-h: 196;
- --background-color-s: 10%;
- --background-color-l: 21%;
-
- --border-width: 1px;
- --border-radius: 5px;
-
- --font-weight: 400;
- --font-size: 16px;
- --font-family: Helvetica;
-
- --input-height: 45px;
- --item-margin: 10px;
-
- --container-max-width: 450px;
- --container-padding: 10px 20px;
-
- --headline-font-weight: 800;
- --headline-font-size: 24px;
-
- --lightness-adjust-dark: 30%;
- --lightness-adjust-dark-light: 10%;
- --lightness-adjust-light: -10%;
- --lightness-adjust-light-dark: 30%;
-}
-
-hanko-auth::part(headline),
-hanko-auth::part(input),
-hanko-auth::part(link) {
- color: hsl(33, 93%, 55%);
-}
-
-hanko-auth::part(link):hover {
- text-decoration: underline;
-}
-
-hanko-auth::part(button):hover,
-hanko-auth::part(input):focus {
- border-width: 2px;
-}
-```
-
-Result:
-
-
-
## Experimental Features
### Conditional Mediation / Autofill assisted Requests
@@ -301,7 +254,7 @@ cause the following issues:
## Frontend framework integrations
-To learn more about how to integrate the `` element into frontend frameworks, see our
+To learn more about how to integrate the Hanko elements into frontend frameworks, see our
[guides](https://docs.hanko.io/guides/frontend) in the official documentation and our
[example applications](../../examples/README.md).
diff --git a/frontend/elements/demo-ui.png b/frontend/elements/demo-ui.png
deleted file mode 100644
index 4e90e891..00000000
Binary files a/frontend/elements/demo-ui.png and /dev/null differ
diff --git a/frontend/elements/demo.gif b/frontend/elements/demo.gif
deleted file mode 100644
index af5c2d50..00000000
Binary files a/frontend/elements/demo.gif and /dev/null differ
diff --git a/frontend/elements/example.css b/frontend/elements/example.css
index 5199221b..0e787570 100644
--- a/frontend/elements/example.css
+++ b/frontend/elements/example.css
@@ -1,3 +1,212 @@
+.hanko_container {
+ background-color: var(--background-color, white);
+ padding: var(--container-padding, 0);
+ max-width: var(--container-max-width, 600px);
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ justify-content: center;
+ align-items: center;
+ align-content: flex-start;
+ box-sizing: border-box
+}
+
+.hanko_content {
+ box-sizing: border-box;
+ flex: 0 1 auto;
+ width: 100%;
+ height: 100%
+}
+
+.hanko_footer {
+ padding: var(--item-margin, 0.5rem 0);
+ box-sizing: border-box;
+ width: 100%
+}
+
+.hanko_footer :nth-child(1) {
+ float: left
+}
+
+.hanko_footer :nth-child(2) {
+ float: right
+}
+
+.hanko_form .hanko_ul {
+ padding-inline-start: 0;
+ list-style-type: none;
+ margin: 0
+}
+
+@media screen and (min-width: 450px) {
+ .hanko_form .hanko_ul {
+ display: flex
+ }
+}
+
+.hanko_form .hanko_li {
+ display: flex
+}
+
+@media screen and (min-width: 450px) {
+ .hanko_form .hanko_li {
+ display: inline-flex
+ }
+
+ .hanko_form .hanko_li:first-child {
+ flex-grow: 1
+ }
+
+ .hanko_form .hanko_li:last-child {
+ margin: 0 0 0 1rem;
+ flex-grow: 0;
+ min-width: 125px
+ }
+
+ .hanko_form .hanko_li:only-child {
+ margin: 0;
+ flex-grow: 1
+ }
+}
+
+.hanko_button {
+ font-weight: var(--font-weight, 400);
+ font-size: var(--font-size, 14px);
+ font-family: var(--font-family, sans-serif);
+ border-radius: var(--border-radius, 4px);
+ border-style: var(--border-style, solid);
+ border-width: var(--border-width, 1px);
+ height: var(--item-height, 34px);
+ margin: var(--item-margin, 0.5rem 0);
+ flex-grow: 1;
+ outline: none;
+ cursor: pointer;
+ transition: .1s ease-out
+}
+
+.hanko_button:disabled {
+ cursor: default
+}
+
+.hanko_button.hanko_primary {
+ color: var(--brand-contrast-color, white);
+ background: var(--brand-color, #506cf0);
+ border-color: var(--brand-color, #506cf0)
+}
+
+.hanko_button.hanko_primary:hover {
+ color: var(--brand-contrast-color, white);
+ background: var(--brand-color-shade-1, #6b84fb);
+ border-color: var(--brand-color, #506cf0)
+}
+
+.hanko_button.hanko_primary:focus {
+ color: var(--brand-contrast-color, white);
+ background: var(--brand-color, #506cf0);
+ border-color: var(--color, #171717)
+}
+
+.hanko_button.hanko_primary:disabled {
+ color: var(--color-shade-1, #8f9095);
+ background: var(--color-shade-2, #e5e6ef);
+ border-color: var(--color-shade-2, #e5e6ef)
+}
+
+.hanko_button.hanko_secondary {
+ color: var(--color, #171717);
+ background: var(--background-color, white);
+ border-color: var(--color, #171717)
+}
+
+.hanko_button.hanko_secondary:hover {
+ color: var(--color, #171717);
+ background: var(--color-shade-2, #e5e6ef);
+ border-color: var(--color, #171717)
+}
+
+.hanko_button.hanko_secondary:focus {
+ color: var(--color, #171717);
+ background: var(--background-color, white);
+ border-color: var(--brand-color, #506cf0)
+}
+
+.hanko_button.hanko_secondary:disabled {
+ color: var(--color-shade-1, #8f9095);
+ background: var(--color-shade-2, #e5e6ef);
+ border-color: var(--color-shade-1, #8f9095)
+}
+
+.hanko_inputWrapper {
+ position: relative;
+ margin: var(--item-margin, 0.5rem 0);
+ display: flex;
+ flex-grow: 1
+}
+
+.hanko_input {
+ font-weight: var(--font-weight, 400);
+ font-size: var(--font-size, 14px);
+ font-family: var(--font-family, sans-serif);
+ border-radius: var(--border-radius, 4px);
+ border-style: var(--border-style, solid);
+ border-width: var(--border-width, 1px);
+ height: var(--item-height, 34px);
+ color: var(--color, #171717);
+ border-color: var(--color-shade-1, #8f9095);
+ background: var(--background-color, white);
+ padding: 0 .5rem;
+ outline: none;
+ width: 100%;
+ box-sizing: border-box;
+ transition: .1s ease-out
+}
+
+.hanko_input:-webkit-autofill,
+.hanko_input:-webkit-autofill:hover,
+.hanko_input:-webkit-autofill:focus {
+ -webkit-text-fill-color: var(--color, #171717);
+ -webkit-box-shadow: 0 0 0 50px var(--background-color, white) inset
+}
+
+.hanko_input::-ms-reveal,
+.hanko_input::-ms-clear {
+ display: none
+}
+
+.hanko_input::placeholder {
+ color: var(--color-shade-1, #8f9095)
+}
+
+.hanko_input:focus {
+ color: var(--color, #171717);
+ border-color: var(--color, #171717)
+}
+
+.hanko_input:disabled {
+ color: var(--color-shade-1, #8f9095);
+ background: var(--color-shade-2, #e5e6ef);
+ border-color: var(--color-shade-1, #8f9095)
+}
+
+.hanko_passcodeInputWrapper {
+ display: flex;
+ justify-content: space-between;
+ margin: var(--item-margin, 0.5rem 0)
+}
+
+.hanko_passcodeInputWrapper .hanko_passcodeDigitWrapper {
+ flex-grow: 1;
+ margin: 0 .5rem 0 0
+}
+
+.hanko_passcodeInputWrapper .hanko_passcodeDigitWrapper:last-child {
+ margin: 0
+}
+
+.hanko_passcodeInputWrapper .hanko_passcodeDigitWrapper .hanko_input {
+ text-align: center
+}
+
.hanko_checkmark {
display: inline-block;
width: 16px;
@@ -10,7 +219,7 @@
display: inline-block;
border-width: 2px;
border-style: solid;
- border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%));
+ border-color: var(--brand-color, #506cf0);
position: absolute;
width: 16px;
height: 16px;
@@ -20,33 +229,33 @@
}
.hanko_checkmark .hanko_circle.hanko_secondary {
- border-color: hsl(var(---background-color-h, 0), var(---background-color-s, 0%), calc(var(---background-color-l, 100%) + 0%))
+ border-color: var(--color-shade-1, #8f9095)
}
.hanko_checkmark .hanko_stem {
position: absolute;
width: 2px;
height: 7px;
- background-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%));
+ background-color: var(--brand-color, #506cf0);
left: 8px;
top: 3px
}
.hanko_checkmark .hanko_stem.hanko_secondary {
- background-color: hsl(var(---background-color-h, 0), var(---background-color-s, 0%), calc(var(---background-color-l, 100%) + 0%))
+ background-color: var(--color-shade-1, #8f9095)
}
.hanko_checkmark .hanko_kick {
position: absolute;
width: 5px;
height: 2px;
- background-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%));
+ background-color: var(--brand-color, #506cf0);
left: 5px;
top: 10px
}
.hanko_checkmark .hanko_kick.hanko_secondary {
- background-color: hsl(var(---background-color-h, 0), var(---background-color-s, 0%), calc(var(---background-color-l, 100%) + 0%))
+ background-color: var(--color-shade-1, #8f9095)
}
.hanko_checkmark.hanko_fadeOut {
@@ -63,291 +272,17 @@
}
}
-.hanko_loadingWheel {
- box-sizing: border-box;
- display: inline-block;
- border-width: 2px;
- border-style: solid;
- border-color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
- border-top: 2px solid hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%));
- border-radius: 50%;
- width: 16px;
- height: 16px;
- animation: hanko_spin 500ms ease-in-out infinite
-}
-
-@keyframes hanko_spin {
- 0% {
- transform: rotate(0deg)
- }
-
- 100% {
- transform: rotate(360deg)
- }
-}
-
-.hanko_loadingIndicator {
- display: inline-block;
- margin: 0 5px
-}
-
-.hanko_button {
- flex-grow: 1;
- outline: none;
- cursor: pointer;
- transition: .1s ease-out
-}
-
-.hanko_button:disabled {
- cursor: default
-}
-
-.hanko_button.hanko_primary {
- font-weight: var(--font-weight, 400);
- font-size: var(--font-size, 16px);
- font-family: var(--font-family, sans-serif);
- color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 0%) + 0%));
- background: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%));
- border-width: var(--border-width, 1.5px);
- border-style: var(--border-style, solid);
- border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%));
- border-radius: var(--border-radius, 3px);
- height: var(--input-height, 50px);
- margin: var(--item-margin, 15px 0)
-}
-
-.hanko_button.hanko_primary:hover {
- color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 0%) + 0%));
- background: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + var(--lightness-adjust-light-dark, 2%)));
- border-width: var(--border-width, 1.5px);
- border-style: var(--border-style, solid);
- border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%))
-}
-
-.hanko_button.hanko_primary:focus {
- color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 0%) + var(--lightness-adjust-light-dark, 2%)));
- background: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + 0%));
- border-width: var(--border-width, 1.5px);
- border-style: var(--border-style, solid);
- border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + var(--lightness-adjust-light-dark, 2%)))
-}
-
-.hanko_button.hanko_primary:disabled {
- color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 0%) + var(--lightness-adjust-light-dark, 2%)));
- background: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + var(--lightness-adjust-light, 5%)));
- border-width: var(--border-width, 1.5px);
- border-style: var(--border-style, solid);
- border-color: hsl(var(--brand-color-h, 230), var(--brand-color-s, 100%), calc(var(--brand-color-l, 90%) + var(--lightness-adjust-light, 5%)))
-}
-
-.hanko_button.hanko_secondary {
- font-weight: var(--font-weight, 400);
- font-size: var(--font-size, 16px);
- font-family: var(--font-family, sans-serif);
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%));
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
- border-width: var(--border-width, 1.5px);
- border-style: var(--border-style, solid);
- border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%)));
- border-radius: var(--border-radius, 3px);
- height: var(--input-height, 50px);
- margin: var(--item-margin, 15px 0)
-}
-
-.hanko_button.hanko_secondary:hover {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%));
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + var(--lightness-adjust-dark-light, -10%)));
- border-width: var(--border-width, 1.5px);
- border-style: var(--border-style, solid);
- border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%))
-}
-
-.hanko_button.hanko_secondary:focus {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%)));
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
- border-width: var(--border-width, 1.5px);
- border-style: var(--border-style, solid);
- border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light-dark, 2%)))
-}
-
-.hanko_button.hanko_secondary:disabled {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light-dark, 2%)));
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + var(--lightness-adjust-light-dark, 2%)));
- border-width: var(--border-width, 1.5px);
- border-style: var(--border-style, solid);
- border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%)))
-}
-
-.hanko_inputWrapper {
- position: relative;
- margin: var(--item-margin, 15px 0);
- display: flex;
- flex-grow: 1
-}
-
-.hanko_label {
- font-weight: var(--font-weight, 400);
- font-size: var(--font-size, 16px);
- font-family: var(--font-family, sans-serif);
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%)));
- left: 0;
- top: 50%;
- position: absolute;
- transform: translateY(-50%);
- padding: 0 .3rem;
- margin: 0 .5rem;
- transition: .1s ease;
- transform-origin: left top;
- pointer-events: none
-}
-
-.hanko_input {
- font-weight: var(--font-weight, 400);
- font-size: var(--font-size, 16px);
- font-family: var(--font-family, sans-serif);
- height: var(--input-height, 50px);
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%)));
- border-style: var(--border-style, solid);
- border-width: var(--border-width, 1.5px);
- border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%)));
- border-radius: var(--border-radius, 3px);
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
- padding: 0 .7rem;
- width: 100%;
- outline: none;
- box-sizing: border-box;
- transition: .1s ease-out
-}
-
-.hanko_input:focus+.hanko_label {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%));
- top: 0;
- transform: translateY(-50%) scale(0.9) !important;
- opacity: 1;
- transition: opacity 1s;
- -webkit-transition: opacity 1s
-}
-
-.hanko_input:not(:placeholder-shown)+.hanko_label {
- top: 0;
- transform: translateY(-50%) scale(0.9) !important
-}
-
-.hanko_input:-webkit-autofill {
- -webkit-box-shadow: 0 0 0 50px hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%)) inset
-}
-
-.hanko_input:-webkit-autofill::first-line {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%))
-}
-
-.hanko_input::-ms-reveal,
-.hanko_input::-ms-clear {
- display: none
-}
-
-.hanko_input:focus {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%));
- border-style: var(--border-style, solid);
- border-width: var(--border-width, 1.5px);
- border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%))
-}
-
-.hanko_input:disabled {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-dark, -30%)));
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + var(--lightness-adjust-dark-light, -10%)));
- border-style: var(--border-style, solid);
- border-width: var(--border-width, 1.5px);
- border-color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-dark, -30%)))
-}
-
-.hanko_passcodeInputWrapper {
- display: flex;
- justify-content: space-between;
- margin: var(--item-margin, 15px 0)
-}
-
-.hanko_passcodeDigitWrapper {
- flex-grow: 1;
- margin: 0 10px 0 0
-}
-
-.hanko_passcodeDigitWrapper:last-child {
- margin: 0
-}
-
-.hanko_passcodeDigitWrapper input {
- text-align: center
-}
-
-.hanko_title {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%));
- font-family: var(--font-family, sans-serif);
- font-size: var(--headline-font-size, 30px);
- font-weight: var(--headline-font-weight, 700);
- display: block;
- margin: var(--item-margin, 15px 0);
- text-align: left;
- letter-spacing: 0;
- font-style: normal
-}
-
-.hanko_content {
- box-sizing: border-box;
- flex: 0 1 auto;
- width: 100%;
- height: 100%
-}
-
-.hanko_ul {
- padding-inline-start: 0;
- list-style-type: none;
- margin: 0
-}
-
-.hanko_li {
- display: flex
-}
-
-.hanko_dividerWrapper {
- font-weight: var(--font-weight, 400);
- font-size: var(--font-size, 16px);
- font-family: var(--font-family, sans-serif);
- display: block;
- visibility: visible;
- margin: var(--item-margin, 15px 0);
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%)))
-}
-
-.hanko_divider {
- border-bottom: var(--border-width, 1.5px) var(--border-style, solid) hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%)));
- color: inherit;
- font: inherit;
- width: 100%;
- text-align: center;
- line-height: .1em;
- margin: 0 auto
-}
-
-.hanko_divider span {
- font: inherit;
- color: inherit;
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
- padding: 0 42px
-}
-
.hanko_exclamationMark {
width: 16px;
height: 16px;
position: relative;
- margin: 10px
+ margin: 5px
}
.hanko_exclamationMark .hanko_circle {
box-sizing: border-box;
display: inline-block;
- background-color: hsl(var(--error-color-h, 351), var(--error-color-s, 100%), calc(var(--error-color-l, 59%) + 0%));
+ background-color: var(--error-color, #e82020);
position: absolute;
width: 16px;
height: 16px;
@@ -360,7 +295,7 @@
position: absolute;
width: 2px;
height: 6px;
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
+ background: var(--background-color, white);
left: 7px;
top: 3px
}
@@ -369,21 +304,77 @@
position: absolute;
width: 2px;
height: 2px;
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
+ background: var(--background-color, white);
left: 7px;
top: 10px
}
+.hanko_loadingSpinnerWrapper {
+ display: inline-block;
+ margin: 0 5px
+}
+
+.hanko_loadingSpinnerWrapper .hanko_loadingSpinner {
+ box-sizing: border-box;
+ display: inline-block;
+ border-width: 2px;
+ border-style: solid;
+ border-color: var(--background-color, white);
+ border-top: 2px solid var(--brand-color, #506cf0);
+ border-radius: 50%;
+ width: 16px;
+ height: 16px;
+ animation: hanko_spin 500ms ease-in-out infinite
+}
+
+.hanko_loadingSpinnerWrapper .hanko_loadingSpinner.hanko_secondary {
+ border-color: var(--color-shade-1, #8f9095);
+ border-top: 2px solid var(--color-shade-2, #e5e6ef)
+}
+
+@keyframes hanko_spin {
+ 0% {
+ transform: rotate(0deg)
+ }
+
+ 100% {
+ transform: rotate(360deg)
+ }
+}
+
+.hanko_headline {
+ color: var(--color, #171717);
+ font-family: var(--font-family, sans-serif);
+ text-align: left;
+ letter-spacing: 0;
+ font-style: normal;
+ line-height: 1.1
+}
+
+.hanko_headline.hanko_grade1 {
+ font-size: var(--headline1-font-size, 24px);
+ font-weight: var(--headline1-font-weight, 600);
+ margin: var(--headline1-margin, 0 0 0.5rem)
+}
+
+.hanko_headline.hanko_grade2 {
+ font-size: var(--headline2-font-size, 14px);
+ font-weight: var(--headline2-font-weight, 600);
+ margin: var(--headline2-margin, 1rem 0 0.25rem)
+}
+
.hanko_errorMessage {
font-weight: var(--font-weight, 400);
- font-size: var(--font-size, 16px);
+ font-size: var(--font-size, 14px);
font-family: var(--font-family, sans-serif);
- color: hsl(var(--error-color-h, 351), var(--error-color-s, 100%), calc(var(--error-color-l, 59%) + 0%));
- background: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
- border: var(--border-width, 1.5px) var(--border-style, solid) hsl(var(--error-color-h, 351), var(--error-color-s, 100%), calc(var(--error-color-l, 59%) + 0%));
- border-radius: var(--border-radius, 3px);
- padding: 5px;
- margin: var(--item-margin, 15px 0);
+ border-radius: var(--border-radius, 4px);
+ border-style: var(--border-style, solid);
+ border-width: var(--border-width, 1px);
+ color: var(--error-color, #e82020);
+ background: var(--background-color, white);
+ padding: .25rem;
+ margin: var(--item-margin, 0.5rem 0);
+ min-height: var(--item-height, 34px);
display: flex;
align-items: center;
box-sizing: border-box
@@ -393,50 +384,163 @@
display: none
}
-.hanko_footer {
- padding: var(--item-margin, 15px 0);
- box-sizing: border-box;
- width: 100%
-}
-
-.hanko_footer :nth-child(1) {
- float: left
-}
-
-.hanko_footer :nth-child(2) {
- float: right
-}
-
.hanko_paragraph {
font-weight: var(--font-weight, 400);
- font-size: var(--font-size, 16px);
+ font-size: var(--font-size, 14px);
font-family: var(--font-family, sans-serif);
+ color: var(--color, #171717);
+ margin: var(--item-margin, 0.5rem 0);
text-align: left;
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%));
- margin: var(--item-margin, 15px 0)
+ word-break: break-word
+}
+
+.hanko_accordion {
+ font-weight: var(--font-weight, 400);
+ font-size: var(--font-size, 14px);
+ font-family: var(--font-family, sans-serif);
+ width: 100%;
+ overflow: hidden
+}
+
+.hanko_accordion .hanko_accordionItem {
+ color: var(--color, #171717);
+ margin: .25rem 0;
+ overflow: hidden
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_label {
+ border-radius: var(--border-radius, 4px);
+ border-style: var(--border-style, solid);
+ border-width: var(--border-width, 1px);
+ border-color: var(--background-color, white);
+ height: var(--item-height, 34px);
+ background: var(--background-color, white);
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 1rem;
+ margin: 0;
+ cursor: pointer;
+ transition: all .35s
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_label .hanko_labelText {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_label .hanko_labelText .hanko_description {
+ color: var(--color-shade-1, #8f9095)
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_label.hanko_dropdown {
+ color: var(--link-color, #506cf0);
+ justify-content: flex-start;
+ width: fit-content
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_label:hover {
+ color: var(--brand-contrast-color, white);
+ background: var(--brand-color-shade-1, #6b84fb)
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_label:hover .hanko_description {
+ color: var(--brand-contrast-color, white)
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_label:hover.hanko_dropdown {
+ color: var(--link-color, #506cf0);
+ border-color: var(--background-color, white);
+ background: none
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_label:not(.hanko_dropdown)::after {
+ content: "❯";
+ width: 1rem;
+ text-align: center;
+ transition: all .35s
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_label.hanko_dropdown::before {
+ content: "+";
+ width: 1em;
+ text-align: center;
+ transition: all .35s
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_accordionInput {
+ position: absolute;
+ opacity: 0;
+ z-index: -1
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label {
+ color: var(--brand-contrast-color, white);
+ background: var(--brand-color, #506cf0)
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label .hanko_description {
+ color: var(--brand-contrast-color, white)
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label.hanko_dropdown {
+ color: var(--link-color, #506cf0);
+ border-color: var(--background-color, white);
+ background: none
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label:not(.hanko_dropdown)::after {
+ transform: rotate(90deg)
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label.hanko_dropdown::before {
+ content: "-"
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_accordionInput:checked+.hanko_label~.hanko_accordionContent {
+ margin: .25rem 1rem;
+ opacity: 1;
+ max-height: 100vh
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_accordionContent {
+ max-height: 0;
+ margin: 0 1rem;
+ opacity: 0;
+ overflow: hidden;
+ transition: all .35s
+}
+
+.hanko_accordion .hanko_accordionItem .hanko_accordionContent.hanko_dropdownContent {
+ border-style: none
}
.hanko_link {
font-weight: var(--font-weight, 400);
- font-size: var(--font-size, 16px);
+ font-size: var(--font-size, 14px);
font-family: var(--font-family, sans-serif);
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light, 5%)));
- text-decoration: none;
- cursor: pointer
+ color: var(--link-color, #506cf0);
+ text-decoration: var(--link-text-decoration, none);
+ cursor: pointer;
+ background: none !important;
+ border: none;
+ padding: 0 !important
}
.hanko_link:hover {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + 0%));
- text-decoration: underline
+ color: var(--link-color, #506cf0);
+ text-decoration: var(--link-text-decoration-hover, underline)
}
.hanko_link.hanko_disabled {
- color: hsl(var(--color-h, 0), var(--color-s, 0%), calc(var(--color-l, 0%) + var(--lightness-adjust-light-dark, 2%)));
+ color: var(--color, #171717);
pointer-events: none;
cursor: default
}
-.hanko_linkWithLoadingIndicator {
+.hanko_linkWrapper {
display: inline-flex;
flex-direction: row;
justify-content: space-between;
@@ -444,19 +548,34 @@
height: 20px
}
-.hanko_linkWithLoadingIndicator.hanko_swap {
+.hanko_linkWrapper.hanko_reverse {
flex-direction: row-reverse
}
-.hanko_container {
- background-color: hsl(var(--background-color-h, 0), var(--background-color-s, 0%), calc(var(--background-color-l, 100%) + 0%));
- padding: var(--container-padding, 0 15px);
- max-width: var(--container-max-width, 600px);
- display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
- justify-content: center;
- align-items: center;
- align-content: flex-start;
- box-sizing: border-box
+.hanko_dividerWrapper {
+ font-weight: var(--font-weight, 400);
+ font-size: var(--font-size, 14px);
+ font-family: var(--font-family, sans-serif);
+ display: var(--divider-display, block);
+ visibility: var(--divider-visibility, visible);
+ color: var(--color-shade-1, #8f9095);
+ margin: var(--item-margin, 0.5rem 0)
+}
+
+.hanko_divider {
+ border-bottom-style: var(--border-style, solid);
+ border-bottom-width: var(--border-width, 1px);
+ color: inherit;
+ font: inherit;
+ width: 100%;
+ text-align: center;
+ line-height: .1em;
+ margin: 0 auto
+}
+
+.hanko_divider .hanko_text {
+ font: inherit;
+ color: inherit;
+ background: var(--background-color, white);
+ padding: var(--divider-padding, 0 42px)
}
diff --git a/frontend/elements/package-lock.json b/frontend/elements/package-lock.json
index efad9b65..2e80e722 100644
--- a/frontend/elements/package-lock.json
+++ b/frontend/elements/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@teamhanko/hanko-elements",
- "version": "0.0.17-alpha",
+ "version": "0.1.0-alpha",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@teamhanko/hanko-elements",
- "version": "0.0.17-alpha",
+ "version": "0.1.0-alpha",
"bundleDependencies": [
"@teamhanko/hanko-frontend-sdk"
],
@@ -40,7 +40,7 @@
},
"../frontend-sdk": {
"name": "@teamhanko/hanko-frontend-sdk",
- "version": "0.0.9-alpha",
+ "version": "0.1.0-alpha",
"license": "MIT",
"dependencies": {
"@types/js-cookie": "^3.0.2"
diff --git a/frontend/elements/package.json b/frontend/elements/package.json
index 696a175c..c2a34908 100644
--- a/frontend/elements/package.json
+++ b/frontend/elements/package.json
@@ -1,6 +1,6 @@
{
"name": "@teamhanko/hanko-elements",
- "version": "0.0.17-alpha",
+ "version": "0.1.0-alpha",
"private": false,
"publishConfig": {
"access": "public"
@@ -13,19 +13,19 @@
"dist"
],
"browser": {
- "./hanko-auth": "./dist/element.hanko-auth.js"
+ "./": "./dist/elements.js"
},
"typesVersions": {
"*": {
- "hanko-auth": [
- "dist/ui/HankoAuth.d.ts"
+ "elements": [
+ "dist/ui/Elements.d.ts"
]
}
},
"exports": {
- "./hanko-auth": {
- "import": "./dist/element.hanko-auth.js",
- "require": "./dist/element.hanko-auth.js",
+ ".": {
+ "import": "./dist/elements.js",
+ "require": "./dist/elements.js",
"types": "./dist/index.d.ts"
}
},
diff --git a/frontend/elements/src/Elements.tsx b/frontend/elements/src/Elements.tsx
new file mode 100644
index 00000000..2ba2e16c
--- /dev/null
+++ b/frontend/elements/src/Elements.tsx
@@ -0,0 +1,97 @@
+import * as preact from "preact";
+import registerCustomElement from "@teamhanko/preact-custom-element";
+
+import AppProvider from "./contexts/AppProvider";
+
+interface AdditionalProps {
+ api: string;
+}
+
+export interface HankoAuthAdditionalProps extends AdditionalProps {
+ experimental?: string;
+}
+
+export interface HankoProfileAdditionalProps extends AdditionalProps {}
+
+declare interface HankoAuthElementProps
+ extends preact.JSX.HTMLAttributes,
+ HankoAuthAdditionalProps {}
+
+declare interface HankoProfileElementProps
+ extends preact.JSX.HTMLAttributes,
+ HankoProfileAdditionalProps {}
+
+declare global {
+ // eslint-disable-next-line no-unused-vars
+ namespace JSX {
+ // eslint-disable-next-line no-unused-vars
+ interface IntrinsicElements {
+ "hanko-auth": HankoAuthElementProps;
+ "hanko-profile": HankoProfileElementProps;
+ }
+ }
+}
+
+export const HankoAuth = (props: HankoAuthElementProps) => (
+
+);
+
+export const HankoProfile = (props: HankoProfileElementProps) => (
+
+);
+
+export interface RegisterOptions {
+ shadow?: boolean;
+ injectStyles?: boolean;
+}
+
+export const register = async (options: RegisterOptions) =>
+ await Promise.all([
+ _register({
+ ...options,
+ tagName: "hanko-auth",
+ entryComponent: HankoAuth,
+ observedAttributes: ["api", "lang", "experimental"],
+ }),
+ _register({
+ ...options,
+ tagName: "hanko-profile",
+ entryComponent: HankoProfile,
+ observedAttributes: ["api", "lang"],
+ }),
+ ]);
+
+interface InternalRegisterOptions extends RegisterOptions {
+ tagName: string;
+ entryComponent: preact.FunctionalComponent;
+ observedAttributes: string[];
+}
+
+const _register = async ({
+ tagName,
+ entryComponent,
+ shadow = true,
+ injectStyles = true,
+ observedAttributes,
+}: InternalRegisterOptions) => {
+ if (!customElements.get(tagName)) {
+ registerCustomElement(entryComponent, tagName, observedAttributes, {
+ shadow,
+ });
+ }
+
+ if (injectStyles) {
+ await customElements.whenDefined(tagName);
+ const elements = document.getElementsByTagName(tagName);
+ const styles = window._hankoStyle;
+
+ Array.from(elements).forEach((element) => {
+ if (shadow) {
+ const clonedStyles = styles.cloneNode(true);
+ element.shadowRoot.appendChild(clonedStyles);
+ } else {
+ element.appendChild(styles);
+ }
+ });
+ }
+};
diff --git a/frontend/elements/src/Translations.ts b/frontend/elements/src/Translations.ts
new file mode 100644
index 00000000..b6858ead
--- /dev/null
+++ b/frontend/elements/src/Translations.ts
@@ -0,0 +1,208 @@
+export const translations = {
+ en: {
+ headlines: {
+ error: "An error has occurred",
+ loginEmail: "Sign in or sign up",
+ loginFinished: "Login successful",
+ loginPasscode: "Enter passcode",
+ loginPassword: "Enter password",
+ registerAuthenticator: "Save a passkey",
+ registerConfirm: "Create account?",
+ registerPassword: "Set new password",
+ profileEmails: "Emails",
+ profilePassword: "Password",
+ profilePasskeys: "Passkeys",
+ isPrimaryEmail: "Primary email address",
+ setPrimaryEmail: "Set primary email address",
+ emailVerified: "Verified",
+ emailUnverified: "Unverified",
+ emailDelete: "Delete",
+ renamePasskey: "Rename passkey",
+ deletePasskey: "Delete passkey",
+ createdAt: "Created at",
+ },
+ texts: {
+ enterPasscode: 'Enter the passcode that was sent to "{emailAddress}".',
+ setupPasskey:
+ "Sign in to your account easily and securely with a passkey. Note: Your biometric data is only stored on your devices and will never be shared with anyone.",
+ createAccount:
+ 'No account exists for "{emailAddress}". Do you want to create a new account?',
+ passwordFormatHint:
+ "Must be between {minLength} and {maxLength} characters long.",
+ manageEmails:
+ "Your email addresses are used for communication and authentication.",
+ changePassword: "Set a new password.",
+ managePasskeys: "Your passkeys allow you to sign in to this account.",
+ isPrimaryEmail:
+ "Used for communication, passcodes, and as username for passkeys. To change the primary email address, add another email address first and set it as primary.",
+ setPrimaryEmail:
+ "Set this email address primary so it will be used for communications, for passcodes, and as a username for passkeys.",
+ emailVerified: "This email address has been verified.",
+ emailUnverified: "This email address has not been verified.",
+ emailDelete:
+ "If you delete this email address, it can no longer be used for signing in to your account. Passkeys that may have been created with this email address will remain intact.",
+ emailDeletePrimary:
+ "The primary email address cannot be deleted. Add another email address first and make it your primary email address.",
+ renamePasskey:
+ "Set a name for the passkey that helps you identify where it is stored.",
+ deletePasskey:
+ "Delete this passkey from your account. Note that the passkey will still exist on your devices and needs to be deleted there as well.",
+ },
+ labels: {
+ or: "or",
+ email: "Email",
+ continue: "Continue",
+ skip: "Skip",
+ save: "Save",
+ password: "Password",
+ signInPassword: "Sign in with a password",
+ signInPasscode: "Sign in with a passcode",
+ forgotYourPassword: "Forgot your password?",
+ back: "Back",
+ signInPasskey: "Sign in with a passkey",
+ registerAuthenticator: "Save a passkey",
+ signIn: "Sign in",
+ signUp: "Sign up",
+ sendNewPasscode: "Send new code",
+ passwordRetryAfter: "Retry in {passwordRetryAfter}",
+ passcodeResendAfter: "Request a new code in {passcodeResendAfter}",
+ unverifiedEmail: "unverified",
+ primaryEmail: "primary",
+ setAsPrimaryEmail: "Set as primary",
+ verify: "Verify",
+ delete: "Delete",
+ newEmailAddress: "New email address",
+ newPassword: "New password",
+ rename: "Rename",
+ newPasskeyName: "New passkey name",
+ addEmail: "Add email",
+ changePassword: "Change password",
+ addPasskey: "Add passkey",
+ webauthnUnsupported: "Passkeys are not supported by your browser",
+ },
+ errors: {
+ somethingWentWrong:
+ "A technical error has occurred. Please try again later.",
+ requestTimeout: "The request timed out.",
+ invalidPassword: "Wrong email or password.",
+ invalidPasscode: "The passcode provided was not correct.",
+ passcodeAttemptsReached:
+ "The passcode was entered incorrectly too many times. Please request a new code.",
+ tooManyRequests:
+ "Too many requests have been made. Please wait to repeat the requested operation.",
+ unauthorized: "Your session has expired. Please log in again.",
+ invalidWebauthnCredential: "Invalid WebAuthn credentials.",
+ passcodeExpired: "The passcode has expired. Please request a new one.",
+ userVerification:
+ "User verification required. Please ensure your authenticator device is protected with a PIN or biometric.",
+ emailAddressAlreadyExistsError: "The email address already exists.",
+ maxNumOfEmailAddressesReached: "No further email addresses can be added.",
+ },
+ },
+ de: {
+ headlines: {
+ error: "Ein Fehler ist aufgetreten",
+ loginEmail: "Anmelden / Registrieren",
+ loginFinished: "Login erfolgreich",
+ loginPasscode: "Passcode eingeben",
+ loginPassword: "Passwort eingeben",
+ registerAuthenticator: "Passkey einrichten",
+ registerConfirm: "Konto erstellen?",
+ registerPassword: "Neues Passwort eingeben",
+ profileEmails: "E-Mails",
+ profilePassword: "Passwort",
+ profilePasskeys: "Passkeys",
+ isPrimaryEmail: "Primäre E-Mail-Adresse",
+ setPrimaryEmail: "Als primäre E-Mail-Adresse festlegen",
+ emailVerified: "Verifiziert",
+ emailUnverified: "Unverifiziert",
+ emailDelete: "Löschen",
+ renamePasskey: "Passkey umbenennen",
+ deletePasskey: "Passkey löschen",
+ createdAt: "Erstellt am",
+ },
+ texts: {
+ enterPasscode:
+ 'Geben Sie den Passcode ein, der an die E-Mail-Adresse "{emailAddress}" gesendet wurde.',
+ setupPasskey:
+ "Ihr Gerät unterstützt die sichere Anmeldung mit Passkeys. Hinweis: Ihre biometrischen Daten verbleiben sicher auf Ihrem Gerät und werden niemals an unseren Server gesendet.",
+ createAccount:
+ 'Es existiert kein Konto für "{emailAddress}". Möchten Sie ein neues Konto erstellen?',
+ passwordFormatHint:
+ "Das Passwort muss zwischen {minLength} und {maxLength} Zeichen lang sein.",
+ manageEmails:
+ "Ihre E-Mail-Adressen werden zur Kommunikation und Authentifizierung verwendet.",
+ changePassword: "Setze ein neues Passwort.",
+ managePasskeys:
+ "Passkeys können für die Anmeldung bei diesem Account verwendet werden.",
+ isPrimaryEmail:
+ "Wird für die Kommunikation, Passcodes und als Benutzername für Passkeys verwendet. Um die primäre E-Mail-Adresse zu ändern, fügen Sie zuerst eine andere E-Mail-Adresse hinzu und legen Sie sie als primär fest.",
+ setPrimaryEmail:
+ "Legen Sie diese E-Mail-Adresse als primär fest, damit sie für die Kommunikation, für Passcodes und als Benutzername für Passkeys genutzt wird.",
+ emailVerified: "Diese E-Mail-Adresse wurde verifiziert.",
+ emailUnverified: "Diese E-Mail-Adresse wurde noch nicht verifiziert.",
+ emailDelete:
+ "Wenn Sie diese E-Mail-Adresse löschen, kann sie nicht mehr für die Anmeldung bei Ihrem Konto verwendet werden. Passkeys, die möglicherweise mit dieser E-Mail-Adresse erstellt wurden, funktionieren weiterhin.",
+ emailDeletePrimary:
+ "Die primäre E-Mail-Adresse kann nicht gelöscht werden. Fügen Sie zuerst eine andere E-Mail-Adresse hinzu und legen Sie diese als primär fest.",
+ renamePasskey:
+ "Legen Sie einen Namen für den Passkey fest, anhand dessen Sie erkennen können, wo er gespeichert ist.",
+ deletePasskey:
+ "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.",
+ },
+ labels: {
+ or: "oder",
+ email: "E-Mail",
+ continue: "Weiter",
+ skip: "Überspringen",
+ save: "Speichern",
+ password: "Passwort",
+ signInPassword: "Mit einem Passwort anmelden",
+ signInPasscode: "Mit einem Passcode anmelden",
+ forgotYourPassword: "Passwort vergessen?",
+ back: "Zurück",
+ signInPasskey: "Anmelden mit Passkey",
+ registerAuthenticator: "Passkey einrichten",
+ signIn: "Anmelden",
+ signUp: "Registrieren",
+ sendNewPasscode: "Neuen Code senden",
+ passwordRetryAfter: "Neuer Versuch in {passwordRetryAfter}",
+ passcodeResendAfter: "Neuen Code in {passcodeResendAfter} anfordern",
+ unverifiedEmail: "unverifiziert",
+ primaryEmail: "primär",
+ setAsPrimaryEmail: "Als primär festlegen",
+ verify: "Verifizieren",
+ delete: "Löschen",
+ newEmailAddress: "Neue E-Mail-Adresse",
+ newPassword: "Neues Passwort",
+ rename: "Umbenennen",
+ newPasskeyName: "Neuer Passkey Name",
+ addEmail: "E-Mail-Adresse hinzufügen",
+ changePassword: "Password ändern",
+ addPasskey: "Passkey hinzufügen",
+ webauthnUnsupported:
+ "Passkeys werden von ihrem Browser nicht unterrstützt",
+ },
+ errors: {
+ somethingWentWrong:
+ "Ein technischer Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.",
+ requestTimeout: "Die Anfrage hat das Zeitlimit überschritten.",
+ invalidPassword: "E-Mail-Adresse oder Passwort falsch.",
+ invalidPasscode: "Der Passcode war nicht richtig.",
+ passcodeAttemptsReached:
+ "Der Passcode wurde zu oft falsch eingegeben. Bitte fragen Sie einen neuen Code an.",
+ tooManyRequests:
+ "Es wurden zu viele Anfragen gestellt. Bitte warten Sie, um den gewünschten Vorgang zu wiederholen.",
+ unauthorized:
+ "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
+ invalidWebauthnCredential: "Ungültiger Berechtigungsnachweis",
+ passcodeExpired:
+ "Der Passcode ist abgelaufen. Bitte fordern Sie einen neuen Code an.",
+ userVerification:
+ "Nutzer-Verifikation erforderlich. Bitte stellen Sie sicher, dass Ihr Gerät durch eine PIN oder Biometrie abgesichert ist.",
+ emailAddressAlreadyExistsError: "Die E-Mail-Adresse existiert bereits.",
+ maxNumOfEmailAddressesReached:
+ "Es können keine weiteren E-Mail-Adressen hinzugefügt werden.",
+ },
+ },
+};
diff --git a/frontend/elements/src/_mixins.sass b/frontend/elements/src/_mixins.sass
new file mode 100644
index 00000000..1ff7619c
--- /dev/null
+++ b/frontend/elements/src/_mixins.sass
@@ -0,0 +1,12 @@
+@use 'variables'
+
+@mixin font
+ font-weight: variables.$font-weight
+ font-size: variables.$font-size
+ font-family: variables.$font-family
+
+@mixin border
+ border-radius: variables.$border-radius
+ border-style: variables.$border-style
+ border-width: variables.$border-width
+
diff --git a/frontend/elements/src/_preset.sass b/frontend/elements/src/_preset.sass
new file mode 100644
index 00000000..ee373546
--- /dev/null
+++ b/frontend/elements/src/_preset.sass
@@ -0,0 +1,54 @@
+// Color Scheme
+$color: #171717
+$color-shade-1: #8f9095
+$color-shade-2: #e5e6ef
+
+$brand-color: #506cf0
+$brand-color-shade-1: #6b84fb
+$brand-contrast-color: white
+
+$background-color: white
+$error-color: #e82020
+$link-color: #506cf0
+
+// Font Styles
+$font-weight: 400
+$font-size: 14px
+$font-family: sans-serif
+
+// Border Styles
+$border-radius: 4px
+$border-style: solid
+$border-width: 1px
+
+// Item Styles
+$item-height: 34px
+$item-margin: .5rem 0
+
+// Container Styles
+$container-padding: 0
+$container-max-width: 600px
+
+// Headline Styles
+$headline1-font-size: 24px
+$headline1-font-weight: 600
+$headline1-margin: 0 0 .5rem
+
+$headline2-font-size: 14px
+$headline2-font-weight: 600
+$headline2-margin: 1rem 0 .25rem
+
+// Divider Styles
+$divider-padding: 0 42px
+$divider-display: block
+$divider-visibility: visible
+
+// Link Styles
+$link-text-decoration: none
+$link-text-decoration-hover: underline
+
+// Input Styles
+$input-min-width: 14em
+
+// Button Styles
+$button-min-width: max-content
diff --git a/frontend/elements/src/_variables.sass b/frontend/elements/src/_variables.sass
new file mode 100644
index 00000000..bcbddea7
--- /dev/null
+++ b/frontend/elements/src/_variables.sass
@@ -0,0 +1,56 @@
+@use 'preset'
+
+// Color Scheme
+$color: var(--color, preset.$color)
+$color-shade-1: var(--color-shade-1, preset.$color-shade-1)
+$color-shade-2: var(--color-shade-2, preset.$color-shade-2)
+
+$brand-color: var(--brand-color, preset.$brand-color)
+$brand-color-shade-1: var(--brand-color-shade-1, preset.$brand-color-shade-1)
+$brand-contrast-color: var(--brand-contrast-color, preset.$brand-contrast-color)
+
+$background-color: var(--background-color, preset.$background-color)
+$error-color: var(--error-color, preset.$error-color)
+$link-color: var(--link-color, preset.$link-color)
+
+// Font Styles
+$font-weight: var(--font-weight, preset.$font-weight)
+$font-size: var(--font-size, preset.$font-size)
+$font-family: var(--font-family, preset.$font-family)
+
+// Border Styles
+$border-radius: var(--border-radius, preset.$border-radius)
+$border-style: var(--border-style, preset.$border-style)
+$border-width: var(--border-width, preset.$border-width)
+
+// Item Styles
+$item-height: var(--item-height, preset.$item-height)
+$item-margin: var(--item-margin, preset.$item-margin)
+
+// Container Styles
+$container-padding: var(--container-padding, preset.$container-padding)
+$container-max-width: var(--container-max-width, preset.$container-max-width)
+
+// Headline Styles
+$headline1-font-weight: var(--headline1-font-weight, preset.$headline1-font-weight)
+$headline1-font-size: var(--headline1-font-size, preset.$headline1-font-size)
+$headline1-margin: var(--headline1-margin, preset.$headline1-margin)
+
+$headline2-font-weight: var(--headline2-font-weight, preset.$headline2-font-weight)
+$headline2-font-size: var(--headline2-font-size, preset.$headline2-font-size)
+$headline2-margin: var(--headline2-margin, preset.$headline2-margin)
+
+// Divider Styles
+$divider-padding: var(--divider-padding, preset.$divider-padding)
+$divider-display: var(--divider-display, preset.$divider-display)
+$divider-visibility: var(--divider-visibility, preset.$divider-visibility)
+
+// Link Styles
+$link-text-decoration: var(--link-text-decoration, preset.$link-text-decoration)
+$link-text-decoration-hover: var(--link-text-decoration-hover, preset.$link-text-decoration-hover)
+
+// Input Styles
+$input-min-width: var(--input-min-width, preset.$input-min-width)
+
+// Button Styles
+$button-min-width: var(--button-min-width, preset.$button-min-width)
diff --git a/frontend/elements/src/components/accordion/Accordion.tsx b/frontend/elements/src/components/accordion/Accordion.tsx
new file mode 100644
index 00000000..170ca7ab
--- /dev/null
+++ b/frontend/elements/src/components/accordion/Accordion.tsx
@@ -0,0 +1,72 @@
+import * as preact from "preact";
+import { h } from "preact";
+import { StateUpdater } from "preact/compat";
+
+import cx from "classnames";
+
+import styles from "./styles.sass";
+
+type Selector = (item: T, itemIndex?: number) => string | h.JSX.Element;
+
+interface Props {
+ name: string;
+ columnSelector: Selector;
+ contentSelector: Selector;
+ checkedItemIndex?: number;
+ setCheckedItemIndex: StateUpdater;
+ data: Array;
+ dropdown?: boolean;
+}
+
+const Accordion = function ({
+ name,
+ columnSelector,
+ contentSelector,
+ data,
+ checkedItemIndex,
+ setCheckedItemIndex,
+ dropdown = false,
+}: Props) {
+ const clickHandler = (event: Event) => {
+ if (!(event.target instanceof HTMLInputElement)) return;
+ const itemIndex = parseInt(event.target.value, 10);
+ setCheckedItemIndex(itemIndex === checkedItemIndex ? null : itemIndex);
+ event.preventDefault();
+ };
+
+ return (
+
+ {data.map((item, itemIndex) => (
+
+
+
+
+ {columnSelector(item, itemIndex)}
+
+
+
+ {contentSelector(item, itemIndex)}
+
+
+ ))}
+
+ );
+};
+
+export default Accordion;
diff --git a/frontend/elements/src/components/accordion/AddEmailDropdown.tsx b/frontend/elements/src/components/accordion/AddEmailDropdown.tsx
new file mode 100644
index 00000000..ddba3fa4
--- /dev/null
+++ b/frontend/elements/src/components/accordion/AddEmailDropdown.tsx
@@ -0,0 +1,158 @@
+import * as preact from "preact";
+import {
+ StateUpdater,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from "preact/compat";
+
+import {
+ Email,
+ HankoError,
+ TooManyRequestsError,
+} from "@teamhanko/hanko-frontend-sdk";
+
+import { AppContext } from "../../contexts/AppProvider";
+import { TranslateContext } from "@denysvuika/preact-translate";
+
+import Form from "../form/Form";
+import Input from "../form/Input";
+import Button from "../form/Button";
+import Dropdown from "./Dropdown";
+
+import LoginPasscodePage from "../../pages/LoginPasscodePage";
+import ProfilePage from "../../pages/ProfilePage";
+
+interface Props {
+ setError: (e: HankoError) => void;
+ checkedItemIndex?: number;
+ setCheckedItemIndex: StateUpdater;
+}
+
+const AddEmailDropdown = ({
+ setError,
+ checkedItemIndex,
+ setCheckedItemIndex,
+}: Props) => {
+ const { t } = useContext(TranslateContext);
+ const { hanko, config, user, setEmails, setPage, setPasscode } =
+ useContext(AppContext);
+
+ const [isSuccess, setIsSuccess] = useState();
+ const [isLoading, setIsLoading] = useState();
+ const [newEmail, setNewEmail] = useState();
+
+ const addEmail = (event: Event) => {
+ event.preventDefault();
+ return config.emails.require_verification
+ ? addEmailWithVerification()
+ : addEmailWithoutVerification();
+ };
+
+ const renderPasscode = useCallback(
+ (email: Email) => {
+ const onSuccessHandler = () => {
+ return hanko.email
+ .list()
+ .then(setEmails)
+ .then(() => setPage( ));
+ };
+
+ const showPasscodePage = (e?: HankoError) =>
+ setPage(
+ setPage( )}
+ />
+ );
+
+ return hanko.passcode
+ .initialize(user.id, email.id, true)
+ .then(setPasscode)
+ .then(() => showPasscodePage())
+ .catch((e) => {
+ if (e instanceof TooManyRequestsError) {
+ showPasscodePage(e);
+ return;
+ }
+ throw e;
+ });
+ },
+ [hanko, newEmail, setEmails, setPage, setPasscode, user.id]
+ );
+
+ const addEmailWithVerification = () => {
+ setIsLoading(true);
+ hanko.email
+ .create(newEmail)
+ .then(renderPasscode)
+ .finally(() => setIsLoading(false))
+ .catch(setError);
+ };
+
+ const addEmailWithoutVerification = () => {
+ hanko.email
+ .create(newEmail)
+ .then(() => hanko.email.list())
+ .then(setEmails)
+ .then(() => {
+ setError(null);
+ setNewEmail("");
+ setIsSuccess(true);
+ setTimeout(() => {
+ setCheckedItemIndex(null);
+ setTimeout(() => {
+ setIsSuccess(false);
+ }, 500);
+ }, 1000);
+ return;
+ })
+ .catch(setError);
+ };
+
+ const onInputHandler = (event: Event) => {
+ event.preventDefault();
+ if (event.target instanceof HTMLInputElement) {
+ setNewEmail(event.target.value);
+ }
+ };
+
+ const disabled = useMemo(
+ () => isSuccess || isLoading,
+ [isLoading, isSuccess]
+ );
+
+ return (
+
+
+
+ );
+};
+
+export default AddEmailDropdown;
diff --git a/frontend/elements/src/components/accordion/AddPasskeyDropdown.tsx b/frontend/elements/src/components/accordion/AddPasskeyDropdown.tsx
new file mode 100644
index 00000000..1831106a
--- /dev/null
+++ b/frontend/elements/src/components/accordion/AddPasskeyDropdown.tsx
@@ -0,0 +1,85 @@
+import * as preact from "preact";
+import { StateUpdater, useContext, useState } from "preact/compat";
+
+import {
+ WebauthnSupport,
+ HankoError,
+ WebauthnRequestCancelledError,
+} from "@teamhanko/hanko-frontend-sdk";
+
+import { AppContext } from "../../contexts/AppProvider";
+import { TranslateContext } from "@denysvuika/preact-translate";
+
+import Form from "../form/Form";
+import Button from "../form/Button";
+import Paragraph from "../paragraph/Paragraph";
+import Dropdown from "./Dropdown";
+
+interface Props {
+ setError: (e: HankoError) => void;
+ checkedItemIndex?: number;
+ setCheckedItemIndex: StateUpdater;
+}
+
+const AddPasskeyDropdown = ({
+ setError,
+ checkedItemIndex,
+ setCheckedItemIndex,
+}: Props) => {
+ const { t } = useContext(TranslateContext);
+ const { hanko, setWebauthnCredentials } = useContext(AppContext);
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSuccess, setIsSuccess] = useState(false);
+
+ const webauthnSupported = WebauthnSupport.supported();
+
+ const addPasskey = (event: Event) => {
+ event.preventDefault();
+ setIsLoading(true);
+ hanko.webauthn
+ .register()
+ .then(() => hanko.webauthn.listCredentials())
+ .then(setWebauthnCredentials)
+ .then(() => {
+ setError(null);
+ setIsSuccess(true);
+ setTimeout(() => {
+ setCheckedItemIndex(null);
+ setTimeout(() => {
+ setIsSuccess(false);
+ }, 500);
+ }, 1000);
+ return;
+ })
+ .finally(() => setIsLoading(false))
+ .catch((e) => {
+ if (!(e instanceof WebauthnRequestCancelledError)) {
+ setError(e);
+ }
+ });
+ };
+
+ return (
+
+ {t("texts.setupPasskey")}
+
+
+ );
+};
+
+export default AddPasskeyDropdown;
diff --git a/frontend/elements/src/components/accordion/ChangePasswordDropdown.tsx b/frontend/elements/src/components/accordion/ChangePasswordDropdown.tsx
new file mode 100644
index 00000000..7508c757
--- /dev/null
+++ b/frontend/elements/src/components/accordion/ChangePasswordDropdown.tsx
@@ -0,0 +1,97 @@
+import * as preact from "preact";
+import { StateUpdater, useContext, useState } from "preact/compat";
+
+import { HankoError } from "@teamhanko/hanko-frontend-sdk";
+
+import { AppContext } from "../../contexts/AppProvider";
+import { TranslateContext } from "@denysvuika/preact-translate";
+
+import Form from "../form/Form";
+import Input from "../form/Input";
+import Button from "../form/Button";
+import Paragraph from "../paragraph/Paragraph";
+import Dropdown from "./Dropdown";
+
+interface Props {
+ setError: (e: HankoError) => void;
+ checkedItemIndex?: number;
+ setCheckedItemIndex: StateUpdater;
+}
+
+const ChangePasswordDropdown = ({
+ setError,
+ checkedItemIndex,
+ setCheckedItemIndex,
+}: Props) => {
+ const { t } = useContext(TranslateContext);
+ const { hanko, config, user } = useContext(AppContext);
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSuccess, setIsSuccess] = useState(false);
+ const [newPassword, setNewPassword] = useState("");
+
+ const changePassword = (event: Event) => {
+ event.preventDefault();
+ setIsLoading(true);
+ hanko.password
+ .update(user.id, newPassword)
+ .then(() => {
+ setNewPassword("");
+ setError(null);
+ setIsSuccess(true);
+ setTimeout(() => {
+ setCheckedItemIndex(null);
+ setTimeout(() => {
+ setIsSuccess(false);
+ }, 500);
+ }, 1000);
+ return;
+ })
+ .finally(() => setIsLoading(false))
+ .catch(setError);
+ };
+
+ const onInputHandler = (event: Event) => {
+ event.preventDefault();
+ if (event.target instanceof HTMLInputElement) {
+ setNewPassword(event.target.value);
+ }
+ };
+
+ return (
+
+
+ {t("texts.passwordFormatHint", {
+ minLength: config.password.min_password_length,
+ maxLength: 72,
+ })}
+
+
+
+ );
+};
+
+export default ChangePasswordDropdown;
diff --git a/frontend/elements/src/components/accordion/Dropdown.tsx b/frontend/elements/src/components/accordion/Dropdown.tsx
new file mode 100644
index 00000000..2f5972df
--- /dev/null
+++ b/frontend/elements/src/components/accordion/Dropdown.tsx
@@ -0,0 +1,35 @@
+import * as preact from "preact";
+import { ComponentChildren, Fragment, h } from "preact";
+import { StateUpdater } from "preact/compat";
+
+import Accordion from "./Accordion";
+
+interface Props {
+ name: string;
+ title: string | h.JSX.Element;
+ children: ComponentChildren;
+ checkedItemIndex?: number;
+ setCheckedItemIndex: StateUpdater;
+}
+
+const Dropdown = ({
+ name,
+ title,
+ children,
+ checkedItemIndex,
+ setCheckedItemIndex,
+}: Props) => {
+ return (
+ title}
+ contentSelector={() => {children} }
+ setCheckedItemIndex={setCheckedItemIndex}
+ checkedItemIndex={checkedItemIndex}
+ data={[{}]}
+ />
+ );
+};
+
+export default Dropdown;
diff --git a/frontend/elements/src/components/accordion/ListEmailsAccordion.tsx b/frontend/elements/src/components/accordion/ListEmailsAccordion.tsx
new file mode 100644
index 00000000..c5348445
--- /dev/null
+++ b/frontend/elements/src/components/accordion/ListEmailsAccordion.tsx
@@ -0,0 +1,242 @@
+import * as preact from "preact";
+import { Fragment } from "preact";
+import {
+ StateUpdater,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from "preact/compat";
+
+import {
+ Email,
+ HankoError,
+ TooManyRequestsError,
+} from "@teamhanko/hanko-frontend-sdk";
+
+import styles from "./styles.sass";
+
+import { AppContext } from "../../contexts/AppProvider";
+import { TranslateContext } from "@denysvuika/preact-translate";
+
+import Accordion from "./Accordion";
+import Paragraph from "../paragraph/Paragraph";
+import Headline2 from "../headline/Headline2";
+import Link from "../link/Link";
+
+import ProfilePage from "../../pages/ProfilePage";
+import LoginPasscodePage from "../../pages/LoginPasscodePage";
+
+interface Props {
+ setError: (e: HankoError) => void;
+ checkedItemIndex?: number;
+ setCheckedItemIndex: StateUpdater;
+}
+
+const ListEmailsAccordion = ({
+ setError,
+ checkedItemIndex,
+ setCheckedItemIndex,
+}: Props) => {
+ const { t } = useContext(TranslateContext);
+ const { hanko, user, emails, setEmails, setPage, setPasscode } =
+ useContext(AppContext);
+
+ const [isPrimaryEmailLoading, setIsPrimaryEmailLoading] =
+ useState(false);
+ const [isVerificationLoading, setIsVerificationLoading] =
+ useState(false);
+ const [isDeletionLoading, setIsDeletionLoading] = useState(false);
+
+ const isDisabled = useMemo(
+ () => isPrimaryEmailLoading || isVerificationLoading || isDeletionLoading,
+ [isDeletionLoading, isPrimaryEmailLoading, isVerificationLoading]
+ );
+
+ const renderPasscode = useCallback(
+ (email: Email) => {
+ const onBackHandler = () => setPage( );
+
+ const showPasscodePage = (e?: HankoError) =>
+ setPage(
+
+ hanko.email.list().then(setEmails).then(onBackHandler)
+ }
+ onBack={onBackHandler}
+ />
+ );
+
+ return hanko.passcode
+ .initialize(user.id, email.id, true)
+ .then(setPasscode)
+ .then(() => showPasscodePage())
+ .catch((e) => {
+ if (e instanceof TooManyRequestsError) {
+ showPasscodePage(e);
+ return;
+ }
+ throw e;
+ });
+ },
+ [hanko.email, hanko.passcode, setEmails, setPage, setPasscode, user.id]
+ );
+
+ const changePrimaryEmail = (event: Event, email: Email) => {
+ event.preventDefault();
+ setIsPrimaryEmailLoading(true);
+ hanko.email
+ .setPrimaryEmail(email.id)
+ .then(() => setError(null))
+ .then(() => hanko.email.list())
+ .then(setEmails)
+ .finally(() => setIsPrimaryEmailLoading(false))
+ .catch(setError);
+ };
+
+ const deleteEmail = (event: Event, email: Email) => {
+ event.preventDefault();
+ setIsDeletionLoading(true);
+ hanko.email
+ .delete(email.id)
+ .then(() => {
+ setError(null);
+ setCheckedItemIndex(null);
+ setIsDeletionLoading(false);
+ return;
+ })
+ .then(() => hanko.email.list())
+ .then(setEmails)
+ .finally(() => setIsDeletionLoading(false))
+ .catch(setError);
+ };
+
+ const verifyEmail = (event: Event, email: Email) => {
+ setIsVerificationLoading(true);
+ renderPasscode(email)
+ .finally(() => setIsVerificationLoading(false))
+ .catch(setError);
+ };
+
+ const labels = (email: Email) => {
+ const description = (
+
+ {!email.is_verified ? (
+
+ {" -"} {t("labels.unverifiedEmail")}
+
+ ) : email.is_primary ? (
+
+ {" -"} {t("labels.primaryEmail")}
+
+ ) : null}
+
+ );
+
+ return email.is_primary ? (
+
+ {email.address}
+ {description}
+
+ ) : (
+
+ {email.address}
+ {description}
+
+ );
+ };
+
+ const contents = (email: Email) => (
+
+ {!email.is_primary ? (
+
+
+ {t("headlines.setPrimaryEmail")}
+ {t("texts.setPrimaryEmail")}
+
+ changePrimaryEmail(event, email)}
+ loadingSpinnerPosition={"right"}
+ >
+ {t("labels.setAsPrimaryEmail")}
+
+
+
+ ) : (
+
+
+ {t("headlines.isPrimaryEmail")}
+ {t("texts.isPrimaryEmail")}
+
+
+ )}
+ {email.is_verified ? (
+
+
+ {t("headlines.emailVerified")}
+ {t("texts.emailVerified")}
+
+
+ ) : (
+
+
+ {t("headlines.emailUnverified")}
+ {t("texts.emailUnverified")}
+
+ verifyEmail(event, email)}
+ loadingSpinnerPosition={"right"}
+ >
+ {t("labels.verify")}
+
+
+
+ )}
+ {!email.is_primary ? (
+
+
+ {t("headlines.emailDelete")}
+ {t("texts.emailDelete")}
+
+ deleteEmail(event, email)}
+ loadingSpinnerPosition={"right"}
+ >
+ {t("labels.delete")}
+
+
+
+ ) : (
+
+
+ {t("headlines.emailDelete")}
+ {t("texts.emailDeletePrimary")}
+
+
+ )}
+
+ );
+ return (
+
+ );
+};
+
+export default ListEmailsAccordion;
diff --git a/frontend/elements/src/components/accordion/ListPasskeysAccordion.tsx b/frontend/elements/src/components/accordion/ListPasskeysAccordion.tsx
new file mode 100644
index 00000000..9255b0a3
--- /dev/null
+++ b/frontend/elements/src/components/accordion/ListPasskeysAccordion.tsx
@@ -0,0 +1,130 @@
+import * as preact from "preact";
+import { Fragment } from "preact";
+import { StateUpdater, useContext, useState } from "preact/compat";
+
+import {
+ HankoError,
+ WebauthnCredentials,
+ WebauthnCredential,
+} from "@teamhanko/hanko-frontend-sdk";
+
+import { AppContext } from "../../contexts/AppProvider";
+import { TranslateContext } from "@denysvuika/preact-translate";
+
+import Accordion from "./Accordion";
+import Paragraph from "../paragraph/Paragraph";
+import Link from "../link/Link";
+import Headline2 from "../headline/Headline2";
+
+import ProfilePage from "../../pages/ProfilePage";
+import RenamePasskeyPage from "../../pages/RenamePasskeyPage";
+
+interface Props {
+ credentials: WebauthnCredentials;
+ setError: (e: HankoError) => void;
+ checkedItemIndex?: number;
+ setCheckedItemIndex: StateUpdater;
+}
+
+const ListPasskeysAccordion = ({
+ credentials,
+ setError,
+ checkedItemIndex,
+ setCheckedItemIndex,
+}: Props) => {
+ const { t } = useContext(TranslateContext);
+ const { hanko, setWebauthnCredentials, setPage } = useContext(AppContext);
+
+ const [isLoading, setIsLoading] = useState(false);
+
+ const deletePasskey = (event: Event, credential: WebauthnCredential) => {
+ event.preventDefault();
+ setIsLoading(true);
+ hanko.webauthn
+ .deleteCredential(credential.id)
+ .then(() => hanko.webauthn.listCredentials())
+ .then(setWebauthnCredentials)
+ .then(() => {
+ setError(null);
+ setCheckedItemIndex(null);
+ return;
+ })
+ .finally(() => setIsLoading(false))
+ .catch(setError);
+ };
+
+ const onBackHandler = () => {
+ setPage( );
+ };
+
+ const renamePasskey = (event: Event, credential: WebauthnCredential) => {
+ event.preventDefault();
+ setPage(
+
+ );
+ };
+
+ const uiDisplayName = (credential: WebauthnCredential) => {
+ if (credential.name) {
+ return credential.name;
+ }
+ const alphanumeric = credential.public_key.replace(/[\W_]/g, "");
+ return `Passkey-${alphanumeric.substring(
+ alphanumeric.length - 7,
+ alphanumeric.length
+ )}`;
+ };
+
+ const convertTime = (t: string) => new Date(t).toLocaleString();
+
+ const labels = (credential: WebauthnCredential) => uiDisplayName(credential);
+
+ const contents = (credential: WebauthnCredential) => (
+
+
+ {t("headlines.renamePasskey")}
+ {t("texts.renamePasskey")}
+
+ renamePasskey(event, credential)}
+ loadingSpinnerPosition={"right"}
+ >
+ {t("labels.rename")}
+
+
+
+ {t("headlines.deletePasskey")}
+ {t("texts.deletePasskey")}
+
+ deletePasskey(event, credential)}
+ loadingSpinnerPosition={"right"}
+ >
+ {t("labels.delete")}
+
+
+
+ {t("headlines.createdAt")}
+ {convertTime(credential.created_at)}
+
+
+ );
+ return (
+
+ );
+};
+
+export default ListPasskeysAccordion;
diff --git a/frontend/elements/src/components/accordion/styles.sass b/frontend/elements/src/components/accordion/styles.sass
new file mode 100644
index 00000000..04275d3f
--- /dev/null
+++ b/frontend/elements/src/components/accordion/styles.sass
@@ -0,0 +1,104 @@
+@use '../../variables'
+@use '../../mixins'
+
+.accordion
+ @include mixins.font
+
+ width: 100%
+ overflow: hidden
+
+ .accordionItem
+ color: variables.$color
+
+ margin: .25rem 0
+ overflow: hidden
+
+ .label
+ border-radius: variables.$border-radius
+ border-style: none
+
+ height: variables.$item-height
+ background: variables.$background-color
+
+ box-sizing: border-box
+ display: flex
+ align-items: center
+ justify-content: space-between
+ padding: 0 1rem
+ margin: 0
+ cursor: pointer
+ transition: all .35s
+
+ .labelText
+ white-space: nowrap
+ overflow: hidden
+ text-overflow: ellipsis
+
+ .description
+ color: variables.$color-shade-1
+
+ &.dropdown
+ color: variables.$link-color
+ justify-content: flex-start
+ width: fit-content
+
+ &:hover
+ color: variables.$brand-contrast-color
+ background: variables.$brand-color-shade-1
+
+ .description
+ color: variables.$brand-contrast-color
+
+ &.dropdown
+ color: variables.$link-color
+ background: none
+
+ &:not(.dropdown)::after
+ content: "\276F"
+ width: 1rem
+ text-align: center
+ transition: all .35s
+
+ &.dropdown::before
+ content: "\002B"
+ width: 1em
+ text-align: center
+ transition: all .35s
+
+ .accordionInput
+ position: absolute
+ opacity: 0
+ z-index: -1
+
+ &:checked
+ + .label
+ color: variables.$brand-contrast-color
+ background: variables.$brand-color
+
+ .description
+ color: variables.$brand-contrast-color
+
+ &.dropdown
+ color: variables.$link-color
+ background: none
+
+ &:not(.dropdown)::after
+ transform: rotate(90deg)
+
+ &.dropdown::before
+ content: "\002D"
+
+ ~ .accordionContent
+ margin: .25rem 1rem
+ opacity: 1
+ max-height: 100vh
+
+ .accordionContent
+ max-height: 0
+ margin: 0 1rem
+ opacity: 0
+ overflow: hidden
+ transition: all .35s
+
+ &.dropdownContent
+ border-style: none
diff --git a/frontend/elements/src/ui/components/Divider.tsx b/frontend/elements/src/components/divider/Divider.tsx
similarity index 89%
rename from frontend/elements/src/ui/components/Divider.tsx
rename to frontend/elements/src/components/divider/Divider.tsx
index a4c34cef..776466be 100644
--- a/frontend/elements/src/ui/components/Divider.tsx
+++ b/frontend/elements/src/components/divider/Divider.tsx
@@ -3,7 +3,7 @@ import { useContext } from "preact/compat";
import { TranslateContext } from "@denysvuika/preact-translate";
-import styles from "./Divider.sass";
+import styles from "./styles.sass";
const Divider = () => {
const { t } = useContext(TranslateContext);
@@ -17,6 +17,7 @@ const Divider = () => {
{t("or")}
diff --git a/frontend/elements/src/components/divider/styles.sass b/frontend/elements/src/components/divider/styles.sass
new file mode 100644
index 00000000..0dd48a34
--- /dev/null
+++ b/frontend/elements/src/components/divider/styles.sass
@@ -0,0 +1,28 @@
+@use '../../variables'
+@use '../../mixins'
+
+.dividerWrapper
+ @include mixins.font
+
+ display: variables.$divider-display
+ visibility: variables.$divider-visibility
+ color: variables.$color-shade-1
+ margin: variables.$item-margin
+
+.divider
+ border-bottom-style: variables.$border-style
+ border-bottom-width: variables.$border-width
+
+ color: inherit
+ font: inherit
+
+ width: 100%
+ text-align: center
+ line-height: .1em
+ margin: 0 auto
+
+ .text
+ font: inherit
+ color: inherit
+ background: variables.$background-color
+ padding: variables.$divider-padding
diff --git a/frontend/elements/src/ui/components/ErrorMessage.tsx b/frontend/elements/src/components/error/ErrorMessage.tsx
similarity index 90%
rename from frontend/elements/src/ui/components/ErrorMessage.tsx
rename to frontend/elements/src/components/error/ErrorMessage.tsx
index 93d2c307..9c50c0b9 100644
--- a/frontend/elements/src/ui/components/ErrorMessage.tsx
+++ b/frontend/elements/src/components/error/ErrorMessage.tsx
@@ -5,9 +5,9 @@ import { TranslateContext } from "@denysvuika/preact-translate";
import { HankoError, TechnicalError } from "@teamhanko/hanko-frontend-sdk";
-import ExclamationMark from "./ExclamationMark";
+import ExclamationMark from "../icons/ExclamationMark";
-import styles from "./ErrorMessage.sass";
+import styles from "./styles.sass";
type Props = {
error?: Error;
diff --git a/frontend/elements/src/components/error/styles.sass b/frontend/elements/src/components/error/styles.sass
new file mode 100644
index 00000000..ff16a3e6
--- /dev/null
+++ b/frontend/elements/src/components/error/styles.sass
@@ -0,0 +1,20 @@
+@use '../../variables'
+@use '../../mixins'
+
+.errorMessage
+ @include mixins.font
+ @include mixins.border
+
+ color: variables.$error-color
+ background: variables.$background-color
+
+ padding: .25rem
+ margin: variables.$item-margin
+ min-height: variables.$item-height
+
+ display: flex
+ align-items: center
+ box-sizing: border-box
+
+ &[hidden]
+ display: none
diff --git a/frontend/elements/src/ui/components/Button.tsx b/frontend/elements/src/components/form/Button.tsx
similarity index 83%
rename from frontend/elements/src/ui/components/Button.tsx
rename to frontend/elements/src/components/form/Button.tsx
index 1f4f6c5e..058cf1e9 100644
--- a/frontend/elements/src/ui/components/Button.tsx
+++ b/frontend/elements/src/components/form/Button.tsx
@@ -4,10 +4,12 @@ import { useEffect, useRef } from "preact/compat";
import cx from "classnames";
-import LoadingIndicator from "./LoadingIndicator";
-import styles from "./Button.sass";
+import styles from "./styles.sass";
+
+import LoadingSpinner from "../icons/LoadingSpinner";
type Props = {
+ title?: string
children: ComponentChildren;
secondary?: boolean;
isLoading?: boolean;
@@ -17,6 +19,7 @@ type Props = {
};
const Button = ({
+ title,
children,
secondary,
disabled,
@@ -37,6 +40,7 @@ const Button = ({
-
{children}
-
+
);
};
diff --git a/frontend/elements/src/ui/components/InputPasscode.tsx b/frontend/elements/src/components/form/CodeInput.tsx
similarity index 72%
rename from frontend/elements/src/ui/components/InputPasscode.tsx
rename to frontend/elements/src/components/form/CodeInput.tsx
index 9ec48fd2..2df3efc6 100644
--- a/frontend/elements/src/ui/components/InputPasscode.tsx
+++ b/frontend/elements/src/components/form/CodeInput.tsx
@@ -1,9 +1,8 @@
import * as preact from "preact";
-import { useEffect, useState } from "preact/compat";
+import { h } from "preact";
+import { useEffect, useMemo, useRef, useState } from "preact/compat";
-import InputPasscodeDigit from "./InputPasscodeDigit";
-
-import styles from "./Input.sass";
+import styles from "./styles.sass";
// Inspired by https://github.com/devfolioco/react-otp-input
@@ -14,7 +13,58 @@ interface Props {
disabled?: boolean;
}
-const InputPasscode = ({
+interface DigitProps extends h.JSX.HTMLAttributes {
+ index: number;
+ focus: boolean;
+ digit: string;
+}
+
+const Digit = ({ index, focus, digit = "", ...props }: DigitProps) => {
+ const ref = useRef(null);
+
+ const focusInput = () => {
+ const { current: element } = ref;
+ if (element) {
+ element.focus();
+ element.select();
+ }
+ };
+
+ // Autofocus if it's the first input element
+ useEffect(() => {
+ if (index === 0) {
+ focusInput();
+ }
+ }, [index, props.disabled]);
+
+ // Focus the current input element
+ useMemo(() => {
+ if (focus) {
+ focusInput();
+ }
+ }, [focus]);
+
+ return (
+
+
+
+ );
+};
+
+const CodeInput = ({
passcodeDigits = [],
numberOfInputs = 6,
onInput,
@@ -116,7 +166,7 @@ const InputPasscode = ({
return (
{Array.from(Array(numberOfInputs)).map((_, index) => (
-
void;
@@ -10,7 +10,7 @@ type Props = {
const Form = ({ onSubmit, children }: Props) => {
return (
-
);
};
-export default InputText;
+export default Input;
diff --git a/frontend/elements/src/components/form/styles.sass b/frontend/elements/src/components/form/styles.sass
new file mode 100644
index 00000000..52376f95
--- /dev/null
+++ b/frontend/elements/src/components/form/styles.sass
@@ -0,0 +1,141 @@
+@use '../../variables'
+@use '../../mixins'
+
+// Form Styles
+.form
+ display: flex
+ flex-grow: 1
+
+ .ul
+ flex-grow: 1
+ margin: variables.$item-margin
+ padding-inline-start: 0
+ list-style-type: none
+ display: flex
+ flex-wrap: wrap
+ gap: 1em
+
+ .li
+ display: flex
+ max-width: 100%
+ flex-grow: 1
+ flex-basis: min-content
+
+// Button Styles
+.button
+ @include mixins.font
+ @include mixins.border
+
+ white-space: nowrap
+ min-width: variables.$button-min-width
+ height: variables.$item-height
+ outline: none
+ cursor: pointer
+ transition: 0.1s ease-out
+ flex-grow: 1
+ flex-shrink: 1
+
+ &:disabled
+ cursor: default
+
+ &.primary
+ color: variables.$brand-contrast-color
+ background: variables.$brand-color
+ border-color: variables.$brand-color
+
+ &.primary:hover
+ color: variables.$brand-contrast-color
+ background: variables.$brand-color-shade-1
+ border-color: variables.$brand-color
+
+ &.primary:focus
+ color: variables.$brand-contrast-color
+ background: variables.$brand-color
+ border-color: variables.$color
+
+ &.primary:disabled
+ color: variables.$color-shade-1
+ background: variables.$color-shade-2
+ border-color: variables.$color-shade-2
+
+ &.secondary
+ color: variables.$color
+ background: variables.$background-color
+ border-color: variables.$color
+
+ &.secondary:hover
+ color: variables.$color
+ background: variables.$color-shade-2
+ border-color: variables.$color
+
+ &.secondary:focus
+ color: variables.$color
+ background: variables.$background-color
+ border-color: variables.$brand-color
+
+ &.secondary:disabled
+ color: variables.$color-shade-1
+ background: variables.$color-shade-2
+ border-color: variables.$color-shade-1
+
+// Input Styles
+
+.inputWrapper
+ flex-grow: 1
+ position: relative
+ display: flex
+ min-width: variables.$input-min-width
+ max-width: 100%
+
+.input
+ @include mixins.font
+ @include mixins.border
+
+ height: variables.$item-height
+ color: variables.$color
+ border-color: variables.$color-shade-1
+ background: variables.$background-color
+
+ padding: 0 .5rem
+ outline: none
+ width: 100%
+ box-sizing: border-box
+ transition: 0.1s ease-out
+
+ &:-webkit-autofill, &:-webkit-autofill:hover, &:-webkit-autofill:focus
+ -webkit-text-fill-color: variables.$color
+ -webkit-box-shadow: 0 0 0 50px variables.$background-color inset
+
+ // Removes native "clear text" and "password reveal" buttons from Edge
+ &::-ms-reveal, &::-ms-clear
+ display: none
+
+ &::placeholder
+ color: variables.$color-shade-1
+
+ &:focus
+ color: variables.$color
+ border-color: variables.$color
+
+ &:disabled
+ color: variables.$color-shade-1
+ background: variables.$color-shade-2
+ border-color: variables.$color-shade-1
+
+.passcodeInputWrapper
+ flex-grow: 1
+ min-width: variables.$input-min-width
+ max-width: fit-content
+ position: relative
+ display: flex
+ justify-content: space-between
+
+ .passcodeDigitWrapper
+ flex-grow: 1
+ margin: 0 .5rem 0 0
+
+ &:last-child
+ margin: 0
+
+ .input
+ text-align: center
diff --git a/frontend/elements/src/components/headline/Headline1.tsx b/frontend/elements/src/components/headline/Headline1.tsx
new file mode 100644
index 00000000..9ded7351
--- /dev/null
+++ b/frontend/elements/src/components/headline/Headline1.tsx
@@ -0,0 +1,24 @@
+import * as preact from "preact";
+import { ComponentChildren } from "preact";
+
+import cx from "classnames";
+
+import styles from "./styles.sass";
+
+type Props = {
+ children: ComponentChildren;
+};
+
+const Headline1 = ({ children }: Props) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default Headline1;
diff --git a/frontend/elements/src/components/headline/Headline2.tsx b/frontend/elements/src/components/headline/Headline2.tsx
new file mode 100644
index 00000000..e8c045a3
--- /dev/null
+++ b/frontend/elements/src/components/headline/Headline2.tsx
@@ -0,0 +1,24 @@
+import * as preact from "preact";
+import { ComponentChildren } from "preact";
+
+import cx from "classnames";
+
+import styles from "./styles.sass";
+
+type Props = {
+ children: ComponentChildren;
+};
+
+const Headline2 = ({ children }: Props) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default Headline2;
diff --git a/frontend/elements/src/components/headline/styles.sass b/frontend/elements/src/components/headline/styles.sass
new file mode 100644
index 00000000..3d99f605
--- /dev/null
+++ b/frontend/elements/src/components/headline/styles.sass
@@ -0,0 +1,22 @@
+@use '../../variables'
+@use '../../mixins'
+
+
+
+.headline
+ color: variables.$color
+ font-family: variables.$font-family
+ text-align: left
+ letter-spacing: 0
+ font-style: normal
+ line-height: 1.1
+
+ &.grade1
+ font-size: variables.$headline1-font-size
+ font-weight: variables.$headline1-font-weight
+ margin: variables.$headline1-margin
+
+ &.grade2
+ font-size: variables.$headline2-font-size
+ font-weight: variables.$headline2-font-weight
+ margin: variables.$headline2-margin
diff --git a/frontend/elements/src/components/icons/Checkmark.tsx b/frontend/elements/src/components/icons/Checkmark.tsx
new file mode 100644
index 00000000..6ebccaae
--- /dev/null
+++ b/frontend/elements/src/components/icons/Checkmark.tsx
@@ -0,0 +1,22 @@
+import * as preact from "preact";
+
+import cx from "classnames";
+
+import styles from "./styles.sass";
+
+type Props = {
+ fadeOut?: boolean;
+ secondary?: boolean;
+};
+
+const Checkmark = ({ fadeOut, secondary }: Props) => {
+ return (
+
+ );
+};
+
+export default Checkmark;
diff --git a/frontend/elements/src/ui/components/ExclamationMark.tsx b/frontend/elements/src/components/icons/ExclamationMark.tsx
similarity index 86%
rename from frontend/elements/src/ui/components/ExclamationMark.tsx
rename to frontend/elements/src/components/icons/ExclamationMark.tsx
index 24bf1a97..c3ce8b4a 100644
--- a/frontend/elements/src/ui/components/ExclamationMark.tsx
+++ b/frontend/elements/src/components/icons/ExclamationMark.tsx
@@ -1,6 +1,6 @@
import * as preact from "preact";
-import styles from "./ExclamationMark.sass";
+import styles from "./styles.sass";
const ExclamationMark = () => {
return (
diff --git a/frontend/elements/src/ui/components/LoadingIndicator.tsx b/frontend/elements/src/components/icons/LoadingSpinner.tsx
similarity index 65%
rename from frontend/elements/src/ui/components/LoadingIndicator.tsx
rename to frontend/elements/src/components/icons/LoadingSpinner.tsx
index de409b23..962057b7 100644
--- a/frontend/elements/src/ui/components/LoadingIndicator.tsx
+++ b/frontend/elements/src/components/icons/LoadingSpinner.tsx
@@ -1,10 +1,11 @@
import * as preact from "preact";
import { ComponentChildren } from "preact";
-import Checkmark from "./Checkmark";
-import LoadingWheel from "./LoadingWheel";
+import cx from "classnames";
-import styles from "./LoadingIndicator.sass";
+import Checkmark from "./Checkmark";
+
+import styles from "./styles.sass";
export type Props = {
children?: ComponentChildren;
@@ -14,7 +15,7 @@ export type Props = {
secondary?: boolean;
};
-const LoadingIndicator = ({
+const LoadingSpinner = ({
children,
isLoading,
isSuccess,
@@ -22,9 +23,11 @@ const LoadingIndicator = ({
secondary,
}: Props) => {
return (
-
+
{isLoading ? (
-
+
) : isSuccess ? (
) : (
@@ -34,4 +37,4 @@ const LoadingIndicator = ({
);
};
-export default LoadingIndicator;
+export default LoadingSpinner;
diff --git a/frontend/elements/src/components/icons/styles.sass b/frontend/elements/src/components/icons/styles.sass
new file mode 100644
index 00000000..f0b26f87
--- /dev/null
+++ b/frontend/elements/src/components/icons/styles.sass
@@ -0,0 +1,121 @@
+@use '../../variables'
+
+// Checkmark Styles
+
+.checkmark
+ display: inline-block
+ width: 16px
+ height: 16px
+ transform: rotate(45deg)
+
+ .circle
+ box-sizing: border-box
+ display: inline-block
+ border-width: 2px
+ border-style: solid
+ border-color: variables.$brand-color
+ position: absolute
+ width: 16px
+ height: 16px
+ border-radius: 11px
+ left: 0
+ top: 0
+
+ &.secondary
+ border-color: variables.$color-shade-1
+
+ .stem
+ position: absolute
+ width: 2px
+ height: 7px
+ background-color: variables.$brand-color
+ left: 8px
+ top: 3px
+
+ &.secondary
+ background-color: variables.$color-shade-1
+
+ .kick
+ position: absolute
+ width: 5px
+ height: 2px
+ background-color: variables.$brand-color
+ left: 5px
+ top: 10px
+
+ &.secondary
+ background-color: variables.$color-shade-1
+
+ &.fadeOut
+ animation: fadeOut ease-out 1.5s forwards !important
+
+@keyframes fadeOut
+ 0%
+ opacity: 1
+
+ 100%
+ opacity: 0
+
+// ExclamationMark Styles
+
+.exclamationMark
+ width: 16px
+ height: 16px
+ position: relative
+ margin: 5px
+
+ .circle
+ box-sizing: border-box
+ display: inline-block
+ background-color: variables.$error-color
+ position: absolute
+ width: 16px
+ height: 16px
+ border-radius: 11px
+ left: 0
+ top: 0
+
+ .stem
+ position: absolute
+ width: 2px
+ height: 6px
+ background: variables.$background-color
+ left: 7px
+ top: 3px
+
+ .dot
+ position: absolute
+ width: 2px
+ height: 2px
+ background: variables.$background-color
+ left: 7px
+ top: 10px
+
+// Loading Spinner Styles
+
+.loadingSpinnerWrapper
+ display: inline-block
+ margin: 0 5px
+
+ .loadingSpinner
+ box-sizing: border-box
+ display: inline-block
+ border-width: 2px
+ border-style: solid
+ border-color: variables.$background-color
+ border-top: 2px solid variables.$brand-color
+ border-radius: 50%
+ width: 16px
+ height: 16px
+ animation: spin 500ms ease-in-out infinite
+
+ &.secondary
+ border-color: variables.$color-shade-1
+ border-top: 2px solid variables.$color-shade-2
+
+@keyframes spin
+ 0%
+ transform: rotate(0deg)
+
+ 100%
+ transform: rotate(360deg)
diff --git a/frontend/elements/src/components/link/Link.tsx b/frontend/elements/src/components/link/Link.tsx
new file mode 100644
index 00000000..54f6b7d2
--- /dev/null
+++ b/frontend/elements/src/components/link/Link.tsx
@@ -0,0 +1,65 @@
+import * as preact from "preact";
+import { Fragment, h } from "preact";
+
+import cx from "classnames";
+
+import LoadingSpinner, {
+ Props as LoadingSpinnerProps,
+} from "../icons/LoadingSpinner";
+
+import styles from "./styles.sass";
+
+type LoadingSpinnerPosition = "left" | "right";
+
+export interface Props
+ extends LoadingSpinnerProps,
+ h.JSX.HTMLAttributes
{
+ dangerous?: boolean;
+ loadingSpinnerPosition?: LoadingSpinnerPosition;
+}
+
+const Link = ({
+ loadingSpinnerPosition,
+ dangerous = false,
+ ...props
+}: Props) => {
+ const renderLink = () => (
+
+ {props.children}
+
+ );
+
+ return (
+
+ {loadingSpinnerPosition ? (
+
+
+ {renderLink()}
+
+ ) : (
+ {renderLink()}
+ )}
+
+ );
+};
+
+export default Link;
diff --git a/frontend/elements/src/components/link/styles.sass b/frontend/elements/src/components/link/styles.sass
new file mode 100644
index 00000000..812f1816
--- /dev/null
+++ b/frontend/elements/src/components/link/styles.sass
@@ -0,0 +1,34 @@
+@use "../../variables"
+@use "../../mixins"
+
+.link
+ @include mixins.font
+
+ color: variables.$link-color
+ text-decoration: variables.$link-text-decoration
+ cursor: pointer
+ background: none!important
+ border: none
+ padding: 0!important
+
+ &:hover
+ text-decoration: variables.$link-text-decoration-hover
+
+ &.disabled
+ color: variables.$color
+ pointer-events: none
+ cursor: default
+
+ &.danger
+ color: variables.$error-color!important
+
+.linkWrapper
+ display: inline-flex
+ flex-direction: row
+ justify-content: space-between
+ align-items: center
+ height: 20px
+
+ &.reverse
+ flex-direction: row-reverse
+
diff --git a/frontend/elements/src/ui/components/Paragraph.tsx b/frontend/elements/src/components/paragraph/Paragraph.tsx
similarity index 89%
rename from frontend/elements/src/ui/components/Paragraph.tsx
rename to frontend/elements/src/components/paragraph/Paragraph.tsx
index 6e13aa31..94f7094f 100644
--- a/frontend/elements/src/ui/components/Paragraph.tsx
+++ b/frontend/elements/src/components/paragraph/Paragraph.tsx
@@ -1,7 +1,7 @@
import * as preact from "preact";
import { ComponentChildren } from "preact";
-import styles from "./Paragraph.sass";
+import styles from "./styles.sass";
type Props = {
children: ComponentChildren;
diff --git a/frontend/elements/src/components/paragraph/styles.sass b/frontend/elements/src/components/paragraph/styles.sass
new file mode 100644
index 00000000..743aca91
--- /dev/null
+++ b/frontend/elements/src/components/paragraph/styles.sass
@@ -0,0 +1,11 @@
+@use "../../variables"
+@use "../../mixins"
+
+.paragraph
+ @include mixins.font
+
+ color: variables.$color
+ margin: variables.$item-margin
+
+ text-align: left
+ word-break: break-word
diff --git a/frontend/elements/src/components/wrapper/Container.tsx b/frontend/elements/src/components/wrapper/Container.tsx
new file mode 100644
index 00000000..236b263b
--- /dev/null
+++ b/frontend/elements/src/components/wrapper/Container.tsx
@@ -0,0 +1,24 @@
+import * as preact from "preact";
+import { ComponentChildren, h } from "preact";
+import { forwardRef } from "preact/compat";
+
+import styles from "./styles.sass";
+
+interface Props extends h.JSX.HTMLAttributes {
+ children: ComponentChildren;
+}
+
+const Container = forwardRef((props: Props, ref) => {
+ return (
+
+ );
+});
+
+export default Container;
diff --git a/frontend/elements/src/ui/components/Content.tsx b/frontend/elements/src/components/wrapper/Content.tsx
similarity index 87%
rename from frontend/elements/src/ui/components/Content.tsx
rename to frontend/elements/src/components/wrapper/Content.tsx
index af0bec88..a1ae7fa1 100644
--- a/frontend/elements/src/ui/components/Content.tsx
+++ b/frontend/elements/src/components/wrapper/Content.tsx
@@ -1,7 +1,7 @@
import * as preact from "preact";
import { ComponentChildren } from "preact";
-import styles from "./Content.sass";
+import styles from "./styles.sass";
type Props = {
children: ComponentChildren;
diff --git a/frontend/elements/src/ui/components/Footer.tsx b/frontend/elements/src/components/wrapper/Footer.tsx
similarity index 88%
rename from frontend/elements/src/ui/components/Footer.tsx
rename to frontend/elements/src/components/wrapper/Footer.tsx
index 29ae1153..d3716416 100644
--- a/frontend/elements/src/ui/components/Footer.tsx
+++ b/frontend/elements/src/components/wrapper/Footer.tsx
@@ -1,7 +1,7 @@
import * as preact from "preact";
import { ComponentChildren } from "preact";
-import styles from "./Footer.sass";
+import styles from "./styles.sass";
interface Props {
children?: ComponentChildren;
diff --git a/frontend/elements/src/components/wrapper/styles.sass b/frontend/elements/src/components/wrapper/styles.sass
new file mode 100644
index 00000000..8356794a
--- /dev/null
+++ b/frontend/elements/src/components/wrapper/styles.sass
@@ -0,0 +1,37 @@
+@use "../../variables"
+
+// Container Styles
+
+.container
+ background-color: variables.$background-color
+ padding: variables.$container-padding
+ max-width: variables.$container-max-width
+
+ display: flex
+ flex-direction: column
+ flex-wrap: nowrap
+ justify-content: center
+ align-items: center
+ align-content: flex-start
+ box-sizing: border-box
+
+// Content Styles
+
+.content
+ box-sizing: border-box
+ flex: 0 1 auto
+ width: 100%
+ height: 100%
+
+// Footer Styles
+
+.footer
+ padding: variables.$item-margin
+ box-sizing: border-box
+ width: 100%
+
+ \:nth-child(1)
+ float: left
+
+ \:nth-child(2)
+ float: right
diff --git a/frontend/elements/src/contexts/AppProvider.tsx b/frontend/elements/src/contexts/AppProvider.tsx
new file mode 100644
index 00000000..8322fbc3
--- /dev/null
+++ b/frontend/elements/src/contexts/AppProvider.tsx
@@ -0,0 +1,149 @@
+import * as preact from "preact";
+import { ComponentChildren, createContext, h } from "preact";
+import { TranslateProvider } from "@denysvuika/preact-translate";
+
+import {
+ StateUpdater,
+ useState,
+ useCallback,
+ useMemo,
+ useRef,
+} from "preact/compat";
+
+import {
+ Hanko,
+ User,
+ UserInfo,
+ Passcode,
+ Emails,
+ Config,
+ WebauthnCredentials,
+} from "@teamhanko/hanko-frontend-sdk";
+
+import { translations } from "../Translations";
+
+import Container from "../components/wrapper/Container";
+
+import InitPage from "../pages/InitPage";
+
+type ExperimentalFeature = "conditionalMediation";
+type ExperimentalFeatures = ExperimentalFeature[];
+type ComponentName = "auth" | "profile";
+
+interface Props {
+ api?: string;
+ lang?: string;
+ fallbackLang?: string;
+ experimental?: string;
+ componentName: ComponentName;
+ children?: ComponentChildren;
+}
+
+interface States {
+ config: Config;
+ setConfig: StateUpdater;
+ userInfo: UserInfo;
+ setUserInfo: StateUpdater;
+ passcode: Passcode;
+ setPasscode: StateUpdater;
+ user: User;
+ setUser: StateUpdater;
+ emails: Emails;
+ setEmails: StateUpdater;
+ webauthnCredentials: WebauthnCredentials;
+ setWebauthnCredentials: StateUpdater;
+ page: h.JSX.Element;
+ setPage: StateUpdater;
+}
+
+interface Context extends States {
+ hanko: Hanko;
+ componentName: ComponentName;
+ experimentalFeatures?: ExperimentalFeatures;
+ emitSuccessEvent: () => void;
+}
+
+export const AppContext = createContext(null);
+
+const AppProvider = ({
+ api,
+ lang,
+ fallbackLang = "en",
+ componentName,
+ experimental = "",
+}: Props) => {
+ const ref = useRef(null);
+
+ const hanko = useMemo(() => {
+ if (api.length) {
+ return new Hanko(api, 13000);
+ }
+ return null;
+ }, [api]);
+
+ const experimentalFeatures = useMemo(
+ () =>
+ experimental
+ .split(" ")
+ .filter((feature) => feature.length)
+ .map((feature) => feature as ExperimentalFeature),
+ [experimental]
+ );
+
+ const emitSuccessEvent = useCallback(() => {
+ const event = new Event("hankoAuthSuccess", {
+ bubbles: true,
+ composed: true,
+ });
+
+ const fn = setTimeout(() => {
+ ref.current.dispatchEvent(event);
+ }, 500);
+
+ return () => clearTimeout(fn);
+ }, []);
+
+ const [config, setConfig] = useState();
+ const [userInfo, setUserInfo] = useState(null);
+ const [passcode, setPasscode] = useState();
+ const [user, setUser] = useState();
+ const [emails, setEmails] = useState();
+ const [webauthnCredentials, setWebauthnCredentials] =
+ useState();
+ const [page, setPage] = useState( );
+
+ return (
+
+
+ {page}
+
+
+ );
+};
+
+export default AppProvider;
diff --git a/frontend/elements/src/index.ts b/frontend/elements/src/index.ts
index 8f318433..1ad7ab31 100644
--- a/frontend/elements/src/index.ts
+++ b/frontend/elements/src/index.ts
@@ -1,2 +1,2 @@
-import { HankoAuth, register } from "./ui/HankoAuth";
-export { HankoAuth, register };
+import { HankoAuth, HankoProfile, register } from "./Elements";
+export { HankoAuth, HankoProfile, register };
diff --git a/frontend/elements/src/pages/ErrorPage.tsx b/frontend/elements/src/pages/ErrorPage.tsx
new file mode 100644
index 00000000..7cc40c79
--- /dev/null
+++ b/frontend/elements/src/pages/ErrorPage.tsx
@@ -0,0 +1,50 @@
+import * as preact from "preact";
+import { useCallback, useContext, useEffect } from "preact/compat";
+
+import { HankoError } from "@teamhanko/hanko-frontend-sdk";
+
+import { TranslateContext } from "@denysvuika/preact-translate";
+import { AppContext } from "../contexts/AppProvider";
+
+import Form from "../components/form/Form";
+import Button from "../components/form/Button";
+import Content from "../components/wrapper/Content";
+import Headline1 from "../components/headline/Headline1";
+import ErrorMessage from "../components/error/ErrorMessage";
+
+import InitPage from "./InitPage";
+
+interface Props {
+ initialError: HankoError;
+}
+
+const ErrorPage = ({ initialError }: Props) => {
+ const { t } = useContext(TranslateContext);
+ const { setPage } = useContext(AppContext);
+
+ const retry = useCallback(() => setPage( ), [setPage]);
+
+ const onContinueClick = (event: Event) => {
+ event.preventDefault();
+ retry();
+ };
+
+ useEffect(() => {
+ addEventListener("hankoAuthSuccess", retry);
+ return () => {
+ removeEventListener("hankoAuthSuccess", retry);
+ };
+ }, [retry]);
+
+ return (
+
+ {t("headlines.error")}
+
+
+
+ );
+};
+
+export default ErrorPage;
diff --git a/frontend/elements/src/pages/InitPage.tsx b/frontend/elements/src/pages/InitPage.tsx
new file mode 100644
index 00000000..4443c0e0
--- /dev/null
+++ b/frontend/elements/src/pages/InitPage.tsx
@@ -0,0 +1,87 @@
+import * as preact from "preact";
+import { useCallback, useContext, useEffect } from "preact/compat";
+
+import { User } from "@teamhanko/hanko-frontend-sdk";
+
+import { AppContext } from "../contexts/AppProvider";
+
+import ErrorPage from "./ErrorPage";
+import ProfilePage from "./ProfilePage";
+import LoginEmailPage from "./LoginEmailPage";
+import LoginFinishedPage from "./LoginFinishedPage";
+import RegisterPasskeyPage from "./RegisterPasskeyPage";
+
+import LoadingSpinner from "../components/icons/LoadingSpinner";
+
+const InitPage = () => {
+ const {
+ hanko,
+ componentName,
+ setConfig,
+ setUser,
+ setEmails,
+ setWebauthnCredentials,
+ setPage,
+ } = useContext(AppContext);
+
+ const afterLogin = useCallback(
+ (_user: User) =>
+ hanko.webauthn
+ .shouldRegister(_user)
+ .then((shouldRegister) =>
+ shouldRegister ? :
+ ),
+ [hanko.webauthn]
+ );
+
+ const initHankoAuth = useCallback(() => {
+ let _user: User;
+ return Promise.allSettled([
+ hanko.config.get().then(setConfig),
+ hanko.user.getCurrent().then((resp) => setUser((_user = resp))),
+ ]).then(([configResult, userResult]) => {
+ if (configResult.status === "rejected") {
+ return ;
+ }
+ if (userResult.status === "fulfilled") {
+ return afterLogin(_user);
+ }
+ return ;
+ });
+ }, [afterLogin, hanko.config, hanko.user, setConfig, setUser]);
+
+ const initHankoProfile = useCallback(
+ () =>
+ Promise.all([
+ hanko.config.get().then(setConfig),
+ hanko.user.getCurrent().then(setUser),
+ hanko.email.list().then(setEmails),
+ hanko.webauthn.listCredentials().then(setWebauthnCredentials),
+ ]).then(() => ),
+ [hanko, setConfig, setEmails, setUser, setWebauthnCredentials]
+ );
+
+ const getInitializer = useCallback(() => {
+ switch (componentName) {
+ case "auth":
+ return initHankoAuth;
+ case "profile":
+ return initHankoProfile;
+ default:
+ return;
+ }
+ }, [componentName, initHankoAuth, initHankoProfile]);
+
+ useEffect(() => {
+ const initializer = getInitializer();
+ if (initializer) {
+ initializer()
+ .then(setPage)
+ .catch((e) => setPage( ));
+ }
+ }, [getInitializer, setPage]);
+
+ return ;
+};
+
+export default InitPage;
diff --git a/frontend/elements/src/pages/LoginEmailPage.tsx b/frontend/elements/src/pages/LoginEmailPage.tsx
new file mode 100644
index 00000000..6422fe1a
--- /dev/null
+++ b/frontend/elements/src/pages/LoginEmailPage.tsx
@@ -0,0 +1,399 @@
+import * as preact from "preact";
+import {
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "preact/compat";
+import { Fragment } from "preact";
+
+import {
+ HankoError,
+ TechnicalError,
+ NotFoundError,
+ WebauthnRequestCancelledError,
+ InvalidWebauthnCredentialError,
+ TooManyRequestsError,
+ WebauthnSupport,
+ UserInfo,
+ User,
+} from "@teamhanko/hanko-frontend-sdk";
+
+import { AppContext } from "../contexts/AppProvider";
+import { TranslateContext } from "@denysvuika/preact-translate";
+
+import Button from "../components/form/Button";
+import Input from "../components/form/Input";
+import Content from "../components/wrapper/Content";
+import Form from "../components/form/Form";
+import Divider from "../components/divider/Divider";
+import ErrorMessage from "../components/error/ErrorMessage";
+import Headline1 from "../components/headline/Headline1";
+
+import LoginPasscodePage from "./LoginPasscodePage";
+import RegisterConfirmPage from "./RegisterConfirmPage";
+import LoginPasswordPage from "./LoginPasswordPage";
+import RegisterPasskeyPage from "./RegisterPasskeyPage";
+import RegisterPasswordPage from "./RegisterPasswordPage";
+import ErrorPage from "./ErrorPage";
+
+interface Props {
+ emailAddress?: string;
+}
+
+const LoginEmailPage = (props: Props) => {
+ const { t } = useContext(TranslateContext);
+ const {
+ hanko,
+ experimentalFeatures,
+ emitSuccessEvent,
+ config,
+ setPage,
+ setPasscode,
+ setUserInfo,
+ setUser,
+ } = useContext(AppContext);
+
+ const [emailAddress, setEmailAddress] = useState(props.emailAddress);
+ const [isPasskeyLoginLoading, setIsPasskeyLoginLoading] = useState();
+ const [isPasskeyLoginSuccess, setIsPasskeyLoginSuccess] = useState();
+ const [isEmailLoginLoading, setIsEmailLoginLoading] = useState();
+ const [error, setError] = useState(null);
+ const [isWebAuthnSupported, setIsWebAuthnSupported] = useState();
+ const [isConditionalMediationSupported, setIsConditionalMediationSupported] =
+ useState();
+ const [isEmailLoginSuccess, setIsEmailLoginSuccess] = useState();
+
+ const disabled = useMemo(
+ () =>
+ isEmailLoginLoading ||
+ isEmailLoginSuccess ||
+ isPasskeyLoginLoading ||
+ isPasskeyLoginSuccess,
+ [
+ isEmailLoginLoading,
+ isEmailLoginSuccess,
+ isPasskeyLoginLoading,
+ isPasskeyLoginSuccess,
+ ]
+ );
+
+ const onEmailInput = (event: Event) => {
+ if (event.target instanceof HTMLInputElement) {
+ setEmailAddress(event.target.value);
+ }
+ };
+
+ const onBackHandler = useCallback(() => {
+ setPage( );
+ }, [emailAddress, setPage]);
+
+ const afterLoginHandler = useCallback(
+ (recoverPassword: boolean) => {
+ let _user: User;
+ return hanko.user
+ .getCurrent()
+ .then((resp) => setUser((_user = resp)))
+ .then(() => hanko.webauthn.shouldRegister(_user))
+ .then((shouldRegisterPasskey) => {
+ const onSuccessHandler = () => {
+ if (shouldRegisterPasskey) {
+ setPage( );
+ return;
+ }
+ emitSuccessEvent();
+ };
+
+ if (recoverPassword) {
+ setPage( );
+ } else {
+ onSuccessHandler();
+ }
+
+ return;
+ })
+ .catch((e) => setPage( ));
+ },
+ [emitSuccessEvent, hanko.user, hanko.webauthn, setPage, setUser]
+ );
+
+ const renderPasscode = useCallback(
+ (userID: string, emailID: string, recoverPassword?: boolean) => {
+ const showPasscodePage = (e?: HankoError) =>
+ setPage(
+ afterLoginHandler(recoverPassword)}
+ onBack={onBackHandler}
+ />
+ );
+
+ return hanko.passcode
+ .initialize(userID, emailID, false)
+ .then(setPasscode)
+ .then(() => showPasscodePage())
+ .catch((e) => {
+ if (e instanceof TooManyRequestsError) {
+ showPasscodePage(e);
+ return;
+ }
+
+ throw e;
+ });
+ },
+ [
+ afterLoginHandler,
+ emailAddress,
+ hanko.passcode,
+ onBackHandler,
+ setPage,
+ setPasscode,
+ ]
+ );
+
+ const renderRegistrationConfirm = useCallback(
+ () =>
+ setPage(
+ afterLoginHandler(config.password.enabled)}
+ onPasscode={(userID: string, emailID: string) =>
+ renderPasscode(userID, emailID, config.password.enabled)
+ }
+ emailAddress={emailAddress}
+ onBack={onBackHandler}
+ />
+ ),
+ [
+ afterLoginHandler,
+ config.password.enabled,
+ emailAddress,
+ onBackHandler,
+ renderPasscode,
+ setPage,
+ ]
+ );
+
+ const loginWithEmailAndWebAuthn = () => {
+ let _userInfo: UserInfo;
+ let webauthnLoginInitiated: boolean;
+
+ return hanko.user
+ .getInfo(emailAddress)
+ .then((resp) => setUserInfo((_userInfo = resp)))
+ .then(() => {
+ if (!_userInfo.verified && config.emails.require_verification) {
+ return renderPasscode(_userInfo.id, _userInfo.email_id);
+ }
+
+ if (!_userInfo.has_webauthn_credential || conditionalMediationEnabled) {
+ return renderAlternateLoginMethod(_userInfo);
+ }
+
+ webauthnLoginInitiated = true;
+ return hanko.webauthn.login(_userInfo.id);
+ })
+ .then(() => {
+ if (webauthnLoginInitiated) {
+ setIsEmailLoginLoading(false);
+ setIsEmailLoginSuccess(true);
+ emitSuccessEvent();
+ }
+
+ return;
+ })
+ .catch((e) => {
+ if (e instanceof NotFoundError) {
+ renderRegistrationConfirm();
+ return;
+ }
+
+ if (e instanceof WebauthnRequestCancelledError) {
+ return renderAlternateLoginMethod(_userInfo);
+ }
+
+ throw e;
+ });
+ };
+
+ const loginWithEmail = () => {
+ let _userInfo: UserInfo;
+ return hanko.user
+ .getInfo(emailAddress)
+ .then((resp) => setUserInfo((_userInfo = resp)))
+ .then(() => {
+ if (!_userInfo.verified && config.emails.require_verification) {
+ return renderPasscode(_userInfo.id, _userInfo.email_id);
+ }
+
+ return renderAlternateLoginMethod(_userInfo);
+ })
+ .catch((e) => {
+ if (e instanceof NotFoundError) {
+ renderRegistrationConfirm();
+ return;
+ }
+
+ throw e;
+ });
+ };
+
+ const onEmailSubmit = (event: Event) => {
+ event.preventDefault();
+ setIsEmailLoginLoading(true);
+
+ if (isWebAuthnSupported) {
+ loginWithEmailAndWebAuthn().catch((e) => {
+ setIsEmailLoginLoading(false);
+ setError(e);
+ });
+ } else {
+ loginWithEmail().catch((e) => {
+ setIsEmailLoginLoading(false);
+ setError(e);
+ });
+ }
+ };
+
+ const onPasskeySubmit = (event: Event) => {
+ event.preventDefault();
+ setIsPasskeyLoginLoading(true);
+
+ hanko.webauthn
+ .login()
+ .then(() => {
+ setError(null);
+ setIsPasskeyLoginLoading(false);
+ setIsPasskeyLoginSuccess(true);
+ emitSuccessEvent();
+
+ return;
+ })
+ .catch((e) => {
+ setIsPasskeyLoginLoading(false);
+ setError(e instanceof WebauthnRequestCancelledError ? null : e);
+ });
+ };
+
+ const conditionalMediationEnabled = useMemo(
+ () =>
+ experimentalFeatures.includes("conditionalMediation") &&
+ isConditionalMediationSupported,
+ [experimentalFeatures, isConditionalMediationSupported]
+ );
+
+ const renderAlternateLoginMethod = useCallback(
+ (_userInfo: UserInfo) => {
+ if (config.password.enabled) {
+ setPage(
+ afterLoginHandler(false)}
+ onRecovery={() =>
+ renderPasscode(_userInfo.id, _userInfo.email_id, true)
+ }
+ onBack={onBackHandler}
+ />
+ );
+ return;
+ }
+
+ return renderPasscode(_userInfo.id, _userInfo.email_id);
+ },
+ [
+ afterLoginHandler,
+ config.password.enabled,
+ onBackHandler,
+ renderPasscode,
+ setPage,
+ ]
+ );
+
+ const loginViaConditionalUI = useCallback(() => {
+ if (!conditionalMediationEnabled) {
+ // Browser doesn't support AutoFill-assisted requests or the experimental conditional mediation feature is not enabled.
+ return;
+ }
+
+ hanko.webauthn
+ .login(null, true)
+ .then(() => {
+ setError(null);
+ emitSuccessEvent();
+ setIsEmailLoginSuccess(true);
+
+ return;
+ })
+ .catch((e) => {
+ if (e instanceof InvalidWebauthnCredentialError) {
+ // An invalid WebAuthn credential has been used. Retry the login procedure, so another credential can be
+ // chosen by the user via conditional UI.
+ loginViaConditionalUI();
+ }
+ setError(e instanceof WebauthnRequestCancelledError ? null : e);
+ });
+ }, [conditionalMediationEnabled, emitSuccessEvent, hanko.webauthn]);
+
+ useEffect(() => {
+ loginViaConditionalUI();
+ }, [loginViaConditionalUI]);
+
+ useEffect(() => {
+ setIsWebAuthnSupported(WebauthnSupport.supported());
+ }, []);
+
+ useEffect(() => {
+ WebauthnSupport.isConditionalMediationAvailable()
+ .then((supported) => setIsConditionalMediationSupported(supported))
+ .catch((e) => setError(new TechnicalError(e)));
+ }, []);
+
+ return (
+
+ {t("headlines.loginEmail")}
+
+
+ {isWebAuthnSupported && !conditionalMediationEnabled ? (
+
+
+
+
+ ) : null}
+
+ );
+};
+
+export default LoginEmailPage;
diff --git a/frontend/elements/src/ui/pages/LoginFinished.tsx b/frontend/elements/src/pages/LoginFinishedPage.tsx
similarity index 57%
rename from frontend/elements/src/ui/pages/LoginFinished.tsx
rename to frontend/elements/src/pages/LoginFinishedPage.tsx
index 652d4ff6..240d80f7 100644
--- a/frontend/elements/src/ui/pages/LoginFinished.tsx
+++ b/frontend/elements/src/pages/LoginFinishedPage.tsx
@@ -2,16 +2,16 @@ import * as preact from "preact";
import { useContext, useState } from "preact/compat";
import { TranslateContext } from "@denysvuika/preact-translate";
-import { RenderContext } from "../contexts/PageProvider";
+import { AppContext } from "../contexts/AppProvider";
-import Headline from "../components/Headline";
-import Content from "../components/Content";
-import Button from "../components/Button";
-import Form from "../components/Form";
+import Headline1 from "../components/headline/Headline1";
+import Content from "../components/wrapper/Content";
+import Button from "../components/form/Button";
+import Form from "../components/form/Form";
-const LoginFinished = () => {
+const LoginFinishedPage = () => {
const { t } = useContext(TranslateContext);
- const { emitSuccessEvent } = useContext(RenderContext);
+ const { emitSuccessEvent } = useContext(AppContext);
const [isSuccess, setIsSuccess] = useState(false);
const onContinue = (event: Event) => {
@@ -22,7 +22,7 @@ const LoginFinished = () => {
return (
- {t("headlines.loginFinished")}
+ {t("headlines.loginFinished")}