mirror of
https://github.com/teamhanko/hanko.git
synced 2025-10-27 14:17:56 +08:00
fix: merge conflicts. remove import in quickstart
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@teamhanko/hanko-elements",
|
||||
"version": "0.2.0-alpha",
|
||||
"version": "0.2.1-alpha",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@ -47,10 +47,10 @@
|
||||
],
|
||||
"homepage": "https://hanko.io",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/parser": "^5.53.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.0",
|
||||
"@typescript-eslint/parser": "^5.54.0",
|
||||
"css-loader": "^6.7.3",
|
||||
"eslint": "^8.33.0",
|
||||
"eslint": "^8.35.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
|
||||
@ -5,9 +5,8 @@ import { TranslateContext } from "@denysvuika/preact-translate";
|
||||
|
||||
import { HankoError, TechnicalError } from "@teamhanko/hanko-frontend-sdk";
|
||||
|
||||
import ExclamationMark from "../icons/ExclamationMark";
|
||||
|
||||
import styles from "./styles.sass";
|
||||
import Icon from "../icons/Icon";
|
||||
|
||||
type Props = {
|
||||
error?: Error;
|
||||
@ -28,7 +27,7 @@ const ErrorMessage = ({ error = defaultError }: Props) => {
|
||||
hidden={!error}
|
||||
>
|
||||
<span>
|
||||
<ExclamationMark />
|
||||
<Icon name={"exclamation"} />
|
||||
</span>
|
||||
<span
|
||||
id="errorMessage"
|
||||
|
||||
@ -16,5 +16,8 @@
|
||||
align-items: center
|
||||
box-sizing: border-box
|
||||
|
||||
&>span:first-child
|
||||
display: inline-flex
|
||||
|
||||
&[hidden]
|
||||
display: none
|
||||
|
||||
@ -7,9 +7,10 @@ import cx from "classnames";
|
||||
import styles from "./styles.sass";
|
||||
|
||||
import LoadingSpinner from "../icons/LoadingSpinner";
|
||||
import Icon, { IconName } from "../icons/Icon";
|
||||
|
||||
type Props = {
|
||||
title?: string
|
||||
title?: string;
|
||||
children: ComponentChildren;
|
||||
secondary?: boolean;
|
||||
isLoading?: boolean;
|
||||
@ -17,6 +18,7 @@ type Props = {
|
||||
disabled?: boolean;
|
||||
autofocus?: boolean;
|
||||
onClick?: (event: Event) => void;
|
||||
icon?: IconName;
|
||||
};
|
||||
|
||||
const Button = ({
|
||||
@ -28,6 +30,7 @@ const Button = ({
|
||||
isSuccess,
|
||||
autofocus,
|
||||
onClick,
|
||||
icon,
|
||||
}: Props) => {
|
||||
const ref = useRef(null);
|
||||
|
||||
@ -56,7 +59,15 @@ const Button = ({
|
||||
isLoading={isLoading}
|
||||
isSuccess={isSuccess}
|
||||
secondary={true}
|
||||
hasIcon={!!icon}
|
||||
>
|
||||
{icon ? (
|
||||
<Icon
|
||||
name={icon}
|
||||
secondary={secondary}
|
||||
disabled={disabled || isLoading || isSuccess}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</LoadingSpinner>
|
||||
</button>
|
||||
|
||||
@ -1,21 +1,25 @@
|
||||
import * as preact from "preact";
|
||||
|
||||
import { IconProps } from "./Icon";
|
||||
import styles from "./styles.sass";
|
||||
import cx from "classnames";
|
||||
|
||||
import styles from "./styles.sass";
|
||||
|
||||
type Props = {
|
||||
fadeOut?: boolean;
|
||||
secondary?: boolean;
|
||||
};
|
||||
|
||||
const Checkmark = ({ fadeOut, secondary }: Props) => {
|
||||
const Checkmark = ({ secondary, size, fadeOut, disabled }: IconProps) => {
|
||||
return (
|
||||
<div className={cx(styles.checkmark, fadeOut && styles.fadeOut)}>
|
||||
<div className={cx(styles.circle, secondary && styles.secondary)} />
|
||||
<div className={cx(styles.stem, secondary && styles.secondary)} />
|
||||
<div className={cx(styles.kick, secondary && styles.secondary)} />
|
||||
</div>
|
||||
<svg
|
||||
id="icon-checkmark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="4 4 40 40"
|
||||
width={size}
|
||||
height={size}
|
||||
className={cx(
|
||||
styles.checkmark,
|
||||
secondary && styles.secondary,
|
||||
fadeOut && styles.fadeOut,
|
||||
disabled && styles.disabled
|
||||
)}
|
||||
>
|
||||
<path d="M21.05 33.1 35.2 18.95l-2.3-2.25-11.85 11.85-6-6-2.25 2.25ZM24 44q-4.1 0-7.75-1.575-3.65-1.575-6.375-4.3-2.725-2.725-4.3-6.375Q4 28.1 4 24q0-4.15 1.575-7.8 1.575-3.65 4.3-6.35 2.725-2.7 6.375-4.275Q19.9 4 24 4q4.15 0 7.8 1.575 3.65 1.575 6.35 4.275 2.7 2.7 4.275 6.35Q44 19.85 44 24q0 4.1-1.575 7.75-1.575 3.65-4.275 6.375t-6.35 4.3Q28.15 44 24 44Zm0-3q7.1 0 12.05-4.975Q41 31.05 41 24q0-7.1-4.95-12.05Q31.1 7 24 7q-7.05 0-12.025 4.95Q7 16.9 7 24q0 7.05 4.975 12.025Q16.95 41 24 41Zm0-17Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
import * as preact from "preact";
|
||||
|
||||
import styles from "./styles.sass";
|
||||
import { IconProps } from "./Icon";
|
||||
import cx from "classnames";
|
||||
|
||||
const ExclamationMark = () => {
|
||||
const ExclamationMark = ({ size, secondary, disabled }: IconProps) => {
|
||||
return (
|
||||
<div className={styles.exclamationMark}>
|
||||
<div className={styles.circle} />
|
||||
<div className={styles.stem} />
|
||||
<div className={styles.dot} />
|
||||
</div>
|
||||
<svg
|
||||
id="icon-exclamation"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={cx(
|
||||
styles.exclamationMark,
|
||||
secondary && styles.secondary,
|
||||
disabled && styles.disabled
|
||||
)}
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
26
frontend/elements/src/components/icons/GitHub.tsx
Normal file
26
frontend/elements/src/components/icons/GitHub.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as preact from "preact";
|
||||
import { IconProps } from "./Icon";
|
||||
import cx from "classnames";
|
||||
import styles from "./styles.sass";
|
||||
|
||||
const GitHub = ({ size, secondary, disabled }: IconProps) => {
|
||||
return (
|
||||
<svg
|
||||
id="icon-github"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="#fff"
|
||||
viewBox="0 0 97.63 96"
|
||||
width={size}
|
||||
height={size}
|
||||
className={cx(
|
||||
styles.icon,
|
||||
secondary && styles.secondary,
|
||||
disabled && styles.disabled
|
||||
)}
|
||||
>
|
||||
<path d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" />{" "}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHub;
|
||||
49
frontend/elements/src/components/icons/Google.tsx
Normal file
49
frontend/elements/src/components/icons/Google.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import * as preact from "preact";
|
||||
import styles from "./styles.sass";
|
||||
import { IconProps } from "./Icon";
|
||||
import cx from "classnames";
|
||||
|
||||
const Google = ({ size, disabled }: IconProps) => {
|
||||
return (
|
||||
<svg
|
||||
id="icon-google"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={styles.googleIcon}
|
||||
>
|
||||
<path
|
||||
className={cx(
|
||||
styles.googleIcon,
|
||||
disabled ? styles.disabled : styles.blue
|
||||
)}
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
className={cx(
|
||||
styles.googleIcon,
|
||||
disabled ? styles.disabled : styles.green
|
||||
)}
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
className={cx(
|
||||
styles.googleIcon,
|
||||
disabled ? styles.disabled : styles.yellow
|
||||
)}
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
className={cx(
|
||||
styles.googleIcon,
|
||||
disabled ? styles.disabled : styles.red
|
||||
)}
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
<path d="M1 1h22v22H1z" fill="none" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Google;
|
||||
36
frontend/elements/src/components/icons/Icon.tsx
Normal file
36
frontend/elements/src/components/icons/Icon.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import * as preact from "preact";
|
||||
import * as icons from "./icons";
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
export type IconProps = {
|
||||
secondary?: boolean;
|
||||
fadeOut?: boolean;
|
||||
disabled?: boolean;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type Props = IconProps & {
|
||||
name: IconName;
|
||||
};
|
||||
|
||||
const Icon = ({
|
||||
name,
|
||||
secondary,
|
||||
size = 18,
|
||||
fadeOut,
|
||||
disabled,
|
||||
}: Props) => {
|
||||
const Ico = icons[name];
|
||||
|
||||
return (
|
||||
<Ico
|
||||
size={size}
|
||||
secondary={secondary}
|
||||
fadeOut={fadeOut}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
@ -1,11 +1,7 @@
|
||||
import * as preact from "preact";
|
||||
import { ComponentChildren } from "preact";
|
||||
|
||||
import cx from "classnames";
|
||||
|
||||
import Checkmark from "./Checkmark";
|
||||
|
||||
import { ComponentChildren, Fragment } from "preact";
|
||||
import styles from "./styles.sass";
|
||||
import Icon from "./Icon";
|
||||
|
||||
export type Props = {
|
||||
children?: ComponentChildren;
|
||||
@ -13,6 +9,7 @@ export type Props = {
|
||||
isSuccess?: boolean;
|
||||
fadeOut?: boolean;
|
||||
secondary?: boolean;
|
||||
hasIcon?: boolean;
|
||||
};
|
||||
|
||||
const LoadingSpinner = ({
|
||||
@ -21,19 +18,30 @@ const LoadingSpinner = ({
|
||||
isSuccess,
|
||||
fadeOut,
|
||||
secondary,
|
||||
hasIcon,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={styles.loadingSpinnerWrapper}>
|
||||
<Fragment>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={cx(styles.loadingSpinner, secondary && styles.secondary)}
|
||||
/>
|
||||
<div className={styles.loadingSpinnerWrapper}>
|
||||
<Icon name={"spinner"} secondary={secondary} />
|
||||
</div>
|
||||
) : isSuccess ? (
|
||||
<Checkmark fadeOut={fadeOut} secondary={secondary} />
|
||||
<div className={styles.loadingSpinnerWrapper}>
|
||||
<Icon name={"checkmark"} secondary={secondary} fadeOut={fadeOut} />
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
<div
|
||||
className={
|
||||
hasIcon
|
||||
? styles.loadingSpinnerWrapperIcon
|
||||
: styles.loadingSpinnerWrapper
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
35
frontend/elements/src/components/icons/Passkey.tsx
Normal file
35
frontend/elements/src/components/icons/Passkey.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import * as preact from "preact";
|
||||
import { IconProps } from "./Icon";
|
||||
import styles from "./styles.sass";
|
||||
import cx from "classnames";
|
||||
|
||||
const Passkey = ({ size, secondary, disabled }: IconProps) => {
|
||||
return (
|
||||
<svg
|
||||
id="icon-passkey"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="3 1.5 19.5 19"
|
||||
width={size}
|
||||
height={size}
|
||||
className={cx(
|
||||
styles.icon,
|
||||
secondary && styles.secondary,
|
||||
disabled && styles.disabled
|
||||
)}
|
||||
>
|
||||
<g id="icon-passkey-all">
|
||||
<circle id="icon-passkey-head" cx="10.5" cy="6" r="4.5" />
|
||||
<path
|
||||
id="icon-passkey-key"
|
||||
d="M22.5,10.5a3.5,3.5,0,1,0-5,3.15V19L19,20.5,21.5,18,20,16.5,21.5,15l-1.24-1.24A3.5,3.5,0,0,0,22.5,10.5Zm-3.5,0a1,1,0,1,1,1-1A1,1,0,0,1,19,10.5Z"
|
||||
/>
|
||||
<path
|
||||
id="icon-passkey-body"
|
||||
d="M14.44,12.52A6,6,0,0,0,12,12H9a6,6,0,0,0-6,6v2H16V14.49A5.16,5.16,0,0,1,14.44,12.52Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Passkey;
|
||||
25
frontend/elements/src/components/icons/Spinner.tsx
Normal file
25
frontend/elements/src/components/icons/Spinner.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import * as preact from "preact";
|
||||
import { IconProps } from "./Icon";
|
||||
import styles from "./styles.sass";
|
||||
import cx from "classnames";
|
||||
|
||||
const Spinner = ({ size, disabled }: IconProps) => {
|
||||
return (
|
||||
<svg
|
||||
id="icon-spinner"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={cx(styles.loadingSpinner, disabled && styles.disabled)}
|
||||
>
|
||||
<path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
/>
|
||||
<path d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
8
frontend/elements/src/components/icons/icons.ts
Normal file
8
frontend/elements/src/components/icons/icons.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { default as passkey } from "./Passkey";
|
||||
import { default as spinner } from "./Spinner";
|
||||
import { default as checkmark } from "./Checkmark";
|
||||
import { default as exclamation } from "./ExclamationMark";
|
||||
import { default as google } from "./Google";
|
||||
import { default as github } from "./GitHub";
|
||||
|
||||
export { passkey, spinner, checkmark, exclamation, google, github };
|
||||
@ -1,50 +1,24 @@
|
||||
@use '../../variables'
|
||||
|
||||
.icon
|
||||
display: inline-block
|
||||
fill: variables.$brand-contrast-color
|
||||
width: 18px
|
||||
|
||||
&.secondary
|
||||
fill: variables.$color
|
||||
|
||||
&.disabled
|
||||
fill: variables.$color-shade-1
|
||||
|
||||
// Checkmark Styles
|
||||
|
||||
.checkmark
|
||||
display: inline-block
|
||||
width: 16px
|
||||
height: 16px
|
||||
transform: rotate(45deg)
|
||||
@extend .icon
|
||||
fill: variables.$brand-color
|
||||
|
||||
.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
|
||||
&.secondary
|
||||
fill: variables.$color-shade-1
|
||||
|
||||
&.fadeOut
|
||||
animation: fadeOut ease-out 1.5s forwards !important
|
||||
@ -59,59 +33,32 @@
|
||||
// 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
|
||||
@extend .icon
|
||||
fill: variables.$error-color
|
||||
padding-right: 5px
|
||||
|
||||
// Loading Spinner Styles
|
||||
|
||||
.loadingSpinnerWrapperIcon
|
||||
@extend .loadingSpinnerWrapper
|
||||
justify-content: flex-start
|
||||
width: 100%
|
||||
column-gap: 10px
|
||||
margin-left: 10px
|
||||
|
||||
.loadingSpinnerWrapper
|
||||
display: inline-block
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
height: 100%
|
||||
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
|
||||
@extend .icon
|
||||
fill: variables.$brand-color
|
||||
animation: spin 500ms ease-in-out infinite
|
||||
|
||||
&.secondary
|
||||
border-color: variables.$color-shade-1
|
||||
border-top: 2px solid variables.$color-shade-2
|
||||
&.secondary
|
||||
fill: variables.$color-shade-1
|
||||
|
||||
@keyframes spin
|
||||
0%
|
||||
@ -119,3 +66,18 @@
|
||||
|
||||
100%
|
||||
transform: rotate(360deg)
|
||||
|
||||
// Google Styles
|
||||
|
||||
.googleIcon
|
||||
&.disabled
|
||||
fill: variables.$color-shade-1
|
||||
|
||||
&.blue
|
||||
fill: #4285F4
|
||||
&.green
|
||||
fill: #34A853
|
||||
&.yellow
|
||||
fill: #FBBC05
|
||||
&.red
|
||||
fill: #EA4335
|
||||
|
||||
@ -77,7 +77,7 @@ const AppProvider = ({
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
|
||||
const hanko = useMemo(() => {
|
||||
if (api.length) {
|
||||
if (api) {
|
||||
return new Hanko(api, 13000);
|
||||
}
|
||||
return null;
|
||||
|
||||
@ -31,7 +31,7 @@ const InitPage = () => {
|
||||
.then((shouldRegister) =>
|
||||
shouldRegister ? <RegisterPasskeyPage /> : <LoginFinishedPage />
|
||||
),
|
||||
[hanko.webauthn]
|
||||
[hanko]
|
||||
);
|
||||
|
||||
const initHankoAuth = useCallback(() => {
|
||||
@ -56,7 +56,7 @@ const InitPage = () => {
|
||||
}
|
||||
return <LoginEmailPage />;
|
||||
});
|
||||
}, [afterLogin, hanko.config, hanko.user, setConfig, setUser]);
|
||||
}, [afterLogin, hanko, setConfig, setUser]);
|
||||
|
||||
const initHankoProfile = useCallback(
|
||||
() =>
|
||||
@ -81,13 +81,14 @@ const InitPage = () => {
|
||||
}, [componentName, initHankoAuth, initHankoProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hanko) return;
|
||||
const initializer = getInitializer();
|
||||
if (initializer) {
|
||||
initializer()
|
||||
.then(setPage)
|
||||
.catch((e) => setPage(<ErrorPage initialError={e} />));
|
||||
}
|
||||
}, [getInitializer, setPage]);
|
||||
}, [hanko, getInitializer, setPage]);
|
||||
|
||||
return <LoadingSpinner isLoading />;
|
||||
};
|
||||
|
||||
@ -30,6 +30,7 @@ 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 { IconName } from "../components/icons/Icon";
|
||||
|
||||
import LoginPasscodePage from "./LoginPasscodePage";
|
||||
import RegisterConfirmPage from "./RegisterConfirmPage";
|
||||
@ -405,6 +406,7 @@ const LoginEmailPage = (props: Props) => {
|
||||
isLoading={isPasskeyLoginLoading}
|
||||
isSuccess={isPasskeyLoginSuccess}
|
||||
disabled={disabled}
|
||||
icon={"passkey"}
|
||||
>
|
||||
{t("labels.signInPasskey")}
|
||||
</Button>
|
||||
@ -421,6 +423,7 @@ const LoginEmailPage = (props: Props) => {
|
||||
secondary
|
||||
isLoading={isThirdPartyLoginLoading === provider}
|
||||
disabled={disabled}
|
||||
icon={provider.toLowerCase() as IconName}
|
||||
>
|
||||
{t("labels.signInWith", {
|
||||
provider,
|
||||
|
||||
@ -82,6 +82,7 @@ const RegisterPasskeyPage = () => {
|
||||
isSuccess={isSuccess}
|
||||
isLoading={isPasskeyLoading}
|
||||
disabled={disabled}
|
||||
icon={"passkey"}
|
||||
>
|
||||
{t("labels.registerAuthenticator")}
|
||||
</Button>
|
||||
|
||||
@ -44,9 +44,9 @@
|
||||
"devDependencies": {
|
||||
"@github/webauthn-json": "^2.1.1",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.54.0",
|
||||
"better-docs": "^2.7.2",
|
||||
"eslint": "^8.33.0",
|
||||
"eslint": "^8.35.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
@ -62,6 +62,6 @@
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/js-cookie": "^3.0.2"
|
||||
"@types/js-cookie": "^3.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,6 +119,7 @@ class Response {
|
||||
class HttpClient {
|
||||
timeout: number;
|
||||
api: string;
|
||||
authCookieName = "hanko";
|
||||
|
||||
// eslint-disable-next-line require-jsdoc
|
||||
constructor(api: string, timeout = 13000) {
|
||||
@ -128,11 +129,10 @@ class HttpClient {
|
||||
|
||||
// eslint-disable-next-line require-jsdoc
|
||||
_fetch(path: string, options: RequestInit, xhr = new XMLHttpRequest()) {
|
||||
const api = this.api;
|
||||
const url = api + path;
|
||||
const self = this;
|
||||
const url = this.api + path;
|
||||
const timeout = this.timeout;
|
||||
const cookieName = "hanko";
|
||||
const bearerToken = Cookies.get(cookieName);
|
||||
const bearerToken = this._getAuthCookie();
|
||||
|
||||
return new Promise<Response>(function (resolve, reject) {
|
||||
xhr.open(options.method, url, true);
|
||||
@ -153,11 +153,7 @@ class HttpClient {
|
||||
|
||||
if (headers.length) {
|
||||
const authToken = xhr.getResponseHeader("X-Auth-Token");
|
||||
|
||||
if (authToken) {
|
||||
const secure = !!api.match("^https://");
|
||||
Cookies.set(cookieName, authToken, { secure });
|
||||
}
|
||||
if (authToken) self._setAuthCookie(authToken);
|
||||
}
|
||||
|
||||
resolve(new Response(xhr));
|
||||
@ -175,6 +171,35 @@ class HttpClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authentication token that was stored in the cookie.
|
||||
*
|
||||
* @return {string}
|
||||
* @return {string}
|
||||
*/
|
||||
_getAuthCookie(): string {
|
||||
return Cookies.get(this.authCookieName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the authentication token to the cookie.
|
||||
*
|
||||
* @param {string} token - The authentication token to be stored.
|
||||
*/
|
||||
_setAuthCookie(token: string) {
|
||||
const secure = !!this.api.match("^https://");
|
||||
Cookies.set(this.authCookieName, token, { secure });
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the cookie used for authentication.
|
||||
*
|
||||
* @param {string} token - The authorization token to be stored.
|
||||
*/
|
||||
removeAuthCookie() {
|
||||
Cookies.remove(this.authCookieName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a GET request.
|
||||
*
|
||||
|
||||
@ -100,6 +100,27 @@ class UserClient extends Client {
|
||||
|
||||
return userResponse.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the current user and expires the existing session cookie. A valid session cookie is required to call the logout endpoint.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
* @throws {TechnicalError}
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
const logoutResponse = await this.client.post("/logout");
|
||||
|
||||
// For cross-domain operations, the frontend SDK creates the cookie by reading the "X-Auth-Token" header, and
|
||||
// "Set-Cookie" headers sent by the backend have no effect due to the browser's security policy, which means that
|
||||
// the cookie must also be removed client-side in that case.
|
||||
this.client.removeAuthCookie();
|
||||
|
||||
if (logoutResponse.status === 401) {
|
||||
return; // The user is logged out already
|
||||
} else if (!logoutResponse.ok) {
|
||||
throw new TechnicalError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { UserClient };
|
||||
|
||||
@ -13,15 +13,15 @@ import {
|
||||
InvalidWebauthnCredentialError,
|
||||
TechnicalError,
|
||||
UnauthorizedError,
|
||||
WebauthnRequestCancelledError,
|
||||
UserVerificationError,
|
||||
WebauthnRequestCancelledError,
|
||||
} from "../Errors";
|
||||
|
||||
import {
|
||||
Attestation,
|
||||
User,
|
||||
WebauthnFinalized,
|
||||
WebauthnCredentials,
|
||||
WebauthnFinalized,
|
||||
} from "../Dto";
|
||||
|
||||
/**
|
||||
|
||||
@ -62,7 +62,7 @@ describe("httpClient._fetch()", () => {
|
||||
this.onload();
|
||||
});
|
||||
|
||||
Cookies.get = jest.fn().mockReturnValue(jwt);
|
||||
jest.spyOn(httpClient, "_getAuthCookie").mockReturnValue(jwt);
|
||||
|
||||
await httpClient._fetch("/test", { method: "GET" }, xhr);
|
||||
|
||||
@ -84,31 +84,12 @@ describe("httpClient._fetch()", () => {
|
||||
});
|
||||
|
||||
jest.spyOn(xhr, "getResponseHeader").mockReturnValue(jwt);
|
||||
|
||||
Cookies.set = jest.fn();
|
||||
jest.spyOn(client, "_setAuthCookie");
|
||||
|
||||
await client._fetch("/test", { method: "GET" }, xhr);
|
||||
|
||||
expect(xhr.getResponseHeader).toHaveBeenCalledWith("X-Auth-Token");
|
||||
expect(Cookies.set).toHaveBeenCalledWith("hanko", jwt, { secure: false });
|
||||
});
|
||||
|
||||
it("should set a secure cookie if x-auth-token response header is available and https is used", async () => {
|
||||
httpClient = new HttpClient("https://test.api");
|
||||
|
||||
jest.spyOn(xhr, "send").mockImplementation(function () {
|
||||
// eslint-disable-next-line no-invalid-this
|
||||
this.onload();
|
||||
});
|
||||
|
||||
jest.spyOn(xhr, "getResponseHeader").mockReturnValue(jwt);
|
||||
|
||||
Cookies.set = jest.fn();
|
||||
|
||||
await httpClient._fetch("/test", { method: "GET" }, xhr);
|
||||
|
||||
expect(xhr.getResponseHeader).toHaveBeenCalledWith("X-Auth-Token");
|
||||
expect(Cookies.set).toHaveBeenCalledWith("hanko", jwt, { secure: true });
|
||||
expect(client._setAuthCookie).toHaveBeenCalledWith(jwt);
|
||||
});
|
||||
|
||||
it("should handle onerror", async () => {
|
||||
@ -134,6 +115,49 @@ describe("httpClient._fetch()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpClient._setAuthCookie()", () => {
|
||||
it("should set a new cookie", async () => {
|
||||
httpClient = new HttpClient("http://test.api");
|
||||
jest.spyOn(Cookies, "set");
|
||||
httpClient._setAuthCookie("test-token");
|
||||
|
||||
expect(Cookies.set).toHaveBeenCalledWith("hanko", "test-token", {
|
||||
secure: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should set a new secure cookie", async () => {
|
||||
httpClient = new HttpClient("https://test.api");
|
||||
jest.spyOn(Cookies, "set");
|
||||
httpClient._setAuthCookie("test-token");
|
||||
|
||||
expect(Cookies.set).toHaveBeenCalledWith("hanko", "test-token", {
|
||||
secure: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpClient._getAuthCookie()", () => {
|
||||
it("should return the contents of the authorization cookie", async () => {
|
||||
httpClient = new HttpClient("https://test.api");
|
||||
Cookies.get = jest.fn().mockReturnValue("test-token");
|
||||
const token = httpClient._getAuthCookie();
|
||||
|
||||
expect(Cookies.get).toHaveBeenCalledWith("hanko");
|
||||
expect(token).toBe("test-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpClient._removeAuthCookie()", () => {
|
||||
it("should return the contents of the authorization cookie", async () => {
|
||||
httpClient = new HttpClient("https://test.api");
|
||||
jest.spyOn(Cookies, "remove");
|
||||
httpClient.removeAuthCookie();
|
||||
|
||||
expect(Cookies.remove).toHaveBeenCalledWith("hanko");
|
||||
});
|
||||
});
|
||||
|
||||
describe("httpClient.get()", () => {
|
||||
it("should call get with correct args", async () => {
|
||||
httpClient._fetch = jest.fn();
|
||||
|
||||
@ -179,3 +179,42 @@ describe("UserClient.create()", () => {
|
||||
await expect(user).rejects.toThrowError("Test error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserClient.logout()", () => {
|
||||
it.each`
|
||||
status
|
||||
${200}
|
||||
${401}
|
||||
`("should return true if logout is successful", async ({ status }) => {
|
||||
const response = new Response(new XMLHttpRequest());
|
||||
response.status = status;
|
||||
response.ok = status >= 200 && status <= 299;
|
||||
|
||||
jest.spyOn(userClient.client, "post").mockResolvedValueOnce(response);
|
||||
await expect(userClient.logout()).resolves.not.toThrow();
|
||||
|
||||
expect(userClient.client.post).toHaveBeenCalledWith("/logout");
|
||||
});
|
||||
|
||||
it.each`
|
||||
status | error
|
||||
${400} | ${"Technical error"}
|
||||
${404} | ${"Technical error"}
|
||||
${500} | ${"Technical error"}
|
||||
`(
|
||||
"should throw error if API returns an error status",
|
||||
async ({ status, error }) => {
|
||||
const response = new Response(new XMLHttpRequest());
|
||||
response.status = status;
|
||||
response.ok = status >= 200 && status <= 299;
|
||||
|
||||
jest
|
||||
.spyOn(userClient.client, "post")
|
||||
.mockResolvedValueOnce(response)
|
||||
|
||||
await expect(userClient.logout()).rejects.toThrow(error);
|
||||
|
||||
expect(userClient.client.post).toHaveBeenCalledWith("/logout");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user