mirror of
https://github.com/teamhanko/hanko.git
synced 2026-03-13 08:43:15 +08:00
482 lines
14 KiB
TypeScript
482 lines
14 KiB
TypeScript
import { JSXInternal } from "preact/src/jsx";
|
|
import { ComponentChildren, createContext, h } from "preact";
|
|
import { TranslateProvider } from "@denysvuika/preact-translate";
|
|
|
|
import {
|
|
Dispatch,
|
|
SetStateAction,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "preact/compat";
|
|
|
|
import {
|
|
Hanko,
|
|
HankoError,
|
|
TechnicalError,
|
|
State,
|
|
FlowName,
|
|
FlowError,
|
|
LastLogin,
|
|
StateInitConfig,
|
|
} from "@teamhanko/hanko-frontend-sdk";
|
|
|
|
import { Translations } from "../i18n/translations";
|
|
|
|
import Container from "../components/wrapper/Container";
|
|
import InitPage from "../pages/InitPage";
|
|
import LoginInitPage from "../pages/LoginInitPage";
|
|
import PasscodePage from "../pages/PasscodePage";
|
|
import RegisterPasskeyPage from "../pages/RegisterPasskeyPage";
|
|
import LoginPasswordPage from "../pages/LoginPasswordPage";
|
|
import EditPasswordPage from "../pages/EditPasswordPage";
|
|
import LoginMethodChooserPage from "../pages/LoginMethodChooser";
|
|
import RegistrationInitPage from "../pages/RegistrationInitPage";
|
|
import CreatePasswordPage from "../pages/CreatePasswordPage";
|
|
import ProfilePage from "../pages/ProfilePage";
|
|
import ErrorPage from "../pages/ErrorPage";
|
|
import CreateEmailPage from "../pages/CreateEmailPage";
|
|
import CreateUsernamePage from "../pages/CreateUsernamePage";
|
|
import CredentialOnboardingChooserPage from "../pages/CredentialOnboardingChooser";
|
|
import LoginOTPPage from "../pages/LoginOTPPage";
|
|
import LoginSecurityKeyPage from "../pages/LoginSecurityKeyPage";
|
|
import MFAMethodChooserPage from "../pages/MFAMethodChooserPage";
|
|
import CreateOTPSecretPage from "../pages/CreateOTPSecretPage";
|
|
import CreateSecurityKeyPage from "../pages/CreateSecurityKeyPage";
|
|
import DeviceTrustPage from "../pages/DeviceTrustPage";
|
|
|
|
import SignalLike = JSXInternal.SignalLike;
|
|
|
|
export type ComponentName =
|
|
| "auth"
|
|
| "login"
|
|
| "registration"
|
|
| "profile"
|
|
| "events";
|
|
|
|
export type HankoAuthMode = "registration" | "login";
|
|
|
|
export interface GlobalOptions {
|
|
hanko?: Hanko;
|
|
injectStyles?: boolean;
|
|
enablePasskeys?: boolean;
|
|
hidePasskeyButtonOnLogin?: boolean;
|
|
translations?: Translations;
|
|
translationsLocation?: string;
|
|
fallbackLanguage?: string;
|
|
storageKey?: string;
|
|
}
|
|
|
|
interface UIState {
|
|
username?: string;
|
|
email?: string;
|
|
error?: FlowError;
|
|
isDisabled?: boolean;
|
|
}
|
|
|
|
interface Context {
|
|
hanko: Hanko;
|
|
setHanko: Dispatch<SetStateAction<Hanko>>;
|
|
page: h.JSX.Element;
|
|
setPage: Dispatch<SetStateAction<h.JSX.Element>>;
|
|
init: (compName: ComponentName) => void;
|
|
componentName: ComponentName;
|
|
setComponentName: Dispatch<SetStateAction<ComponentName>>;
|
|
lang: string;
|
|
hidePasskeyButtonOnLogin: boolean;
|
|
prefilledEmail?: string;
|
|
prefilledUsername?: string;
|
|
uiState: UIState;
|
|
setUIState: Dispatch<SetStateAction<UIState>>;
|
|
initialComponentName: ComponentName;
|
|
lastLogin?: LastLogin;
|
|
isOwnFlow: (state: State<any>) => boolean;
|
|
}
|
|
|
|
export const AppContext = createContext<Context>(null);
|
|
|
|
interface Props {
|
|
lang?: string | SignalLike<string>;
|
|
prefilledEmail?: string;
|
|
prefilledUsername?: string;
|
|
mode?: HankoAuthMode;
|
|
nonce?: string;
|
|
componentName: ComponentName;
|
|
globalOptions: GlobalOptions;
|
|
children?: ComponentChildren;
|
|
createWebauthnAbortSignal: () => AbortSignal;
|
|
}
|
|
|
|
const AppProvider = ({
|
|
lang,
|
|
prefilledEmail,
|
|
prefilledUsername,
|
|
globalOptions,
|
|
createWebauthnAbortSignal,
|
|
nonce,
|
|
...props
|
|
}: Props) => {
|
|
const {
|
|
hanko,
|
|
injectStyles,
|
|
hidePasskeyButtonOnLogin,
|
|
translations,
|
|
translationsLocation,
|
|
fallbackLanguage,
|
|
} = globalOptions;
|
|
|
|
// Without this, the initial "lang" attribute value sometimes appears to not
|
|
// be set properly. This results in a wrong X-Language header value being sent
|
|
// to the API and hence in outgoing emails translated in the wrong language.
|
|
hanko.setLang(lang?.toString() || fallbackLanguage);
|
|
|
|
const ref = useRef<HTMLElement>(null);
|
|
|
|
const storageKeyLastLogin = useMemo(
|
|
() => `${globalOptions.storageKey}_last_login`,
|
|
[globalOptions.storageKey],
|
|
);
|
|
|
|
const [componentName, setComponentName] = useState<ComponentName>(
|
|
props.componentName,
|
|
);
|
|
|
|
const [authComponentFlow, setAuthComponentFlow] = useState<FlowName>(
|
|
props.mode ?? "login",
|
|
);
|
|
|
|
// TODO: check if necessary, see also TODO below
|
|
const hasInitializedRef = useRef(false);
|
|
const [isReadyToInit, setIsReadyToInit] = useState(false);
|
|
|
|
const componentFlowNameMap = useMemo<Record<ComponentName, FlowName>>(
|
|
() => ({
|
|
auth: authComponentFlow,
|
|
login: "login",
|
|
registration: "registration",
|
|
profile: "profile",
|
|
events: null,
|
|
}),
|
|
[authComponentFlow],
|
|
);
|
|
|
|
const initComponent = useMemo(() => <InitPage />, []);
|
|
const [page, setPage] = useState<h.JSX.Element>(initComponent);
|
|
const [, setHanko] = useState<Hanko>(hanko);
|
|
const [lastLogin, setLastLogin] = useState<LastLogin>();
|
|
const [uiState, setUIState] = useState<UIState>({
|
|
email: prefilledEmail,
|
|
username: prefilledUsername,
|
|
});
|
|
|
|
const dispatchEvent = function <T>(type: string, detail?: T) {
|
|
ref.current?.dispatchEvent(
|
|
new CustomEvent<T>(type, {
|
|
detail,
|
|
bubbles: false,
|
|
composed: true,
|
|
}),
|
|
);
|
|
};
|
|
|
|
const isOwnFlow = useCallback(
|
|
(state: State<any>) =>
|
|
componentFlowNameMap[componentName] == state.flowName,
|
|
[componentFlowNameMap, componentName, authComponentFlow],
|
|
);
|
|
|
|
const handleError = (e: any) => {
|
|
setPage(
|
|
<ErrorPage error={e instanceof HankoError ? e : new TechnicalError(e)} />,
|
|
);
|
|
};
|
|
|
|
useMemo(
|
|
() =>
|
|
hanko.onBeforeStateChange(({ state }) => {
|
|
if (!isOwnFlow(state)) {
|
|
return;
|
|
}
|
|
|
|
setUIState((prev) => ({ ...prev, isDisabled: true, error: undefined }));
|
|
}),
|
|
[hanko, isOwnFlow],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setUIState((prev) => ({
|
|
...prev,
|
|
...(prefilledEmail && { email: prefilledEmail }),
|
|
...(prefilledUsername && { username: prefilledUsername }),
|
|
}));
|
|
}, [prefilledEmail, prefilledUsername]);
|
|
|
|
useEffect(
|
|
() =>
|
|
hanko.onAfterStateChange(async ({ state }) => {
|
|
if (!isOwnFlow(state)) {
|
|
return;
|
|
}
|
|
if (
|
|
![
|
|
"onboarding_verify_passkey_attestation",
|
|
"webauthn_credential_verification",
|
|
"login_passkey",
|
|
"thirdparty",
|
|
].includes(state.name)
|
|
) {
|
|
setUIState((prev) => ({ ...prev, isDisabled: false }));
|
|
}
|
|
|
|
switch (state.name) {
|
|
case "login_init":
|
|
setPage(<LoginInitPage state={state} />);
|
|
state.passkeyAutofillActivation();
|
|
break;
|
|
case "passcode_confirmation":
|
|
setPage(<PasscodePage state={state} />);
|
|
break;
|
|
case "login_otp":
|
|
setPage(<LoginOTPPage state={state} />);
|
|
break;
|
|
case "onboarding_create_passkey":
|
|
setPage(<RegisterPasskeyPage state={state} />);
|
|
break;
|
|
case "login_password":
|
|
setPage(<LoginPasswordPage state={state} />);
|
|
break;
|
|
case "login_password_recovery":
|
|
setPage(<EditPasswordPage state={state} />);
|
|
break;
|
|
case "login_security_key":
|
|
setPage(<LoginSecurityKeyPage state={state} />);
|
|
break;
|
|
case "mfa_method_chooser":
|
|
setPage(<MFAMethodChooserPage state={state} />);
|
|
break;
|
|
case "mfa_otp_secret_creation":
|
|
setPage(<CreateOTPSecretPage state={state} />);
|
|
break;
|
|
case "mfa_security_key_creation":
|
|
setPage(<CreateSecurityKeyPage state={state} />);
|
|
break;
|
|
case "login_method_chooser":
|
|
setPage(<LoginMethodChooserPage state={state} />);
|
|
break;
|
|
case "registration_init":
|
|
setPage(<RegistrationInitPage state={state} />);
|
|
break;
|
|
case "password_creation":
|
|
setPage(<CreatePasswordPage state={state} />);
|
|
break;
|
|
case "success":
|
|
if (state.payload?.last_login) {
|
|
localStorage.setItem(
|
|
storageKeyLastLogin,
|
|
JSON.stringify(state.payload.last_login),
|
|
);
|
|
}
|
|
state.autoStep();
|
|
break;
|
|
case "profile_init":
|
|
setPage(
|
|
<ProfilePage
|
|
state={state}
|
|
enablePasskeys={globalOptions.enablePasskeys}
|
|
/>,
|
|
);
|
|
break;
|
|
case "error":
|
|
setPage(<ErrorPage state={state} />);
|
|
break;
|
|
case "onboarding_email":
|
|
setPage(<CreateEmailPage state={state} />);
|
|
break;
|
|
case "onboarding_username":
|
|
setPage(<CreateUsernamePage state={state} />);
|
|
break;
|
|
case "credential_onboarding_chooser":
|
|
setPage(<CredentialOnboardingChooserPage state={state} />);
|
|
break;
|
|
case "device_trust":
|
|
setPage(<DeviceTrustPage state={state} />);
|
|
break;
|
|
}
|
|
}),
|
|
[componentName, componentFlowNameMap],
|
|
);
|
|
|
|
const flowInit = useCallback(async (flowName: FlowName) => {
|
|
setUIState((prev) => ({ ...prev, isDisabled: true }));
|
|
const lastLoginEncoded = localStorage.getItem(storageKeyLastLogin);
|
|
if (lastLoginEncoded) {
|
|
setLastLogin(JSON.parse(lastLoginEncoded) as LastLogin);
|
|
}
|
|
const samlHint = new URLSearchParams(window.location.search).get(
|
|
"saml_hint",
|
|
);
|
|
const config: StateInitConfig = {
|
|
excludeAutoSteps: ["success"],
|
|
cacheKey: `hanko-auth-flow-state`,
|
|
dispatchAfterStateChangeEvent: false,
|
|
};
|
|
|
|
if (samlHint === "idp_initiated") {
|
|
setAuthComponentFlow("token_exchange");
|
|
await hanko.createState("token_exchange", {
|
|
...config,
|
|
dispatchAfterStateChangeEvent: true,
|
|
});
|
|
} else {
|
|
const state = await hanko.createState(flowName, config);
|
|
setAuthComponentFlow(state.flowName);
|
|
setTimeout(() => state.dispatchAfterStateChangeEvent(), 500);
|
|
}
|
|
}, []);
|
|
|
|
const init = useCallback(
|
|
(compName: ComponentName) => {
|
|
setComponentName(compName);
|
|
const flowName = componentFlowNameMap[compName];
|
|
|
|
if (flowName) {
|
|
flowInit(flowName).catch(handleError);
|
|
}
|
|
},
|
|
[componentFlowNameMap],
|
|
);
|
|
|
|
// TODO: check if this can be done in cleaner way
|
|
// Step 1: Set the authComponentFlow from props.mode
|
|
useEffect(() => {
|
|
if (!hasInitializedRef.current) {
|
|
const timer = setTimeout(() => {
|
|
setAuthComponentFlow(props.mode ?? "login");
|
|
setIsReadyToInit(true);
|
|
}, 0);
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [props.mode]);
|
|
|
|
// Step 2: Call init after authComponentFlow has been updated
|
|
useEffect(() => {
|
|
if (isReadyToInit && !hasInitializedRef.current) {
|
|
hasInitializedRef.current = true;
|
|
init(componentName);
|
|
}
|
|
}, [isReadyToInit, authComponentFlow, componentName, init]);
|
|
|
|
useEffect(() => {
|
|
const cleanUserDeleted = hanko.onUserDeleted(() => {
|
|
dispatchEvent("onUserDeleted");
|
|
});
|
|
|
|
const cleanSessionCreated = hanko.onSessionCreated((detail) => {
|
|
dispatchEvent("onSessionCreated", detail);
|
|
});
|
|
|
|
const cleanSessionExpired = hanko.onSessionExpired(() => {
|
|
dispatchEvent("onSessionExpired");
|
|
});
|
|
|
|
const cleanUserLoggedOut = hanko.onUserLoggedOut(() => {
|
|
dispatchEvent("onUserLoggedOut");
|
|
});
|
|
|
|
const cleanBeforeStateChange = hanko.onBeforeStateChange((detail) => {
|
|
dispatchEvent("onBeforeStateChange", detail);
|
|
});
|
|
|
|
const cleanAfterStateChange = hanko.onAfterStateChange((detail) => {
|
|
dispatchEvent("onAfterStateChange", detail);
|
|
});
|
|
|
|
return () => {
|
|
cleanUserDeleted();
|
|
cleanSessionCreated();
|
|
cleanSessionExpired();
|
|
cleanUserLoggedOut();
|
|
cleanBeforeStateChange();
|
|
cleanAfterStateChange();
|
|
};
|
|
}, [hanko]);
|
|
|
|
useEffect(() => {
|
|
const cb = () => {
|
|
init(componentName);
|
|
};
|
|
if (["auth", "login", "registration"].includes(componentName)) {
|
|
const cleanUserLoggedOut = hanko.onUserLoggedOut(cb);
|
|
const cleanSessionExpired = hanko.onSessionExpired(cb);
|
|
const cleanUserDeleted = hanko.onUserDeleted(cb);
|
|
return () => {
|
|
cleanUserLoggedOut();
|
|
cleanSessionExpired();
|
|
cleanUserDeleted();
|
|
};
|
|
} else if (componentName === "profile") {
|
|
const cleanSessionCreated = hanko.onSessionCreated(cb);
|
|
return () => {
|
|
cleanSessionCreated();
|
|
};
|
|
}
|
|
}, [componentName, hanko, init]);
|
|
|
|
// Cast Provider to any to bypass strict JSX return type check (TS2786)
|
|
// TODO: Find out why, we this need to be casted to any for the build to work.
|
|
const AppContextProviderAny = AppContext.Provider as any;
|
|
const TranslateContextProviderAny = TranslateProvider as any;
|
|
const ContainerAny = Container as any;
|
|
|
|
return (
|
|
<AppContextProviderAny
|
|
value={{
|
|
init,
|
|
initialComponentName: props.componentName,
|
|
setUIState,
|
|
uiState,
|
|
hanko,
|
|
setHanko,
|
|
lang: lang?.toString() || fallbackLanguage,
|
|
prefilledEmail,
|
|
prefilledUsername,
|
|
componentName,
|
|
setComponentName,
|
|
hidePasskeyButtonOnLogin,
|
|
page,
|
|
setPage,
|
|
lastLogin,
|
|
isOwnFlow,
|
|
}}
|
|
>
|
|
<TranslateContextProviderAny
|
|
translations={translations}
|
|
fallbackLang={fallbackLanguage}
|
|
root={translationsLocation}
|
|
>
|
|
<ContainerAny ref={ref}>
|
|
{componentName !== "events" ? (
|
|
<>
|
|
{injectStyles ? (
|
|
<style
|
|
nonce={nonce || undefined}
|
|
/* eslint-disable-next-line react/no-danger */
|
|
dangerouslySetInnerHTML={{
|
|
__html: window._hankoStyle.innerHTML,
|
|
}}
|
|
/>
|
|
) : null}
|
|
{page}
|
|
</>
|
|
) : null}
|
|
</ContainerAny>
|
|
</TranslateContextProviderAny>
|
|
</AppContextProviderAny>
|
|
);
|
|
};
|
|
|
|
export default AppProvider;
|