From 021f79cfb653de693dbbabae4deb5b43d338dd94 Mon Sep 17 00:00:00 2001 From: Kasper Mroz Date: Fri, 10 Jan 2025 17:30:36 +0100 Subject: [PATCH 01/31] HRD in Console WIP --- packages/react/package.json | 2 +- .../form/org-switching/org-switching-flow.ts | 471 ++++++++++++++++++ .../form/org-switching/org-switching-form.tsx | 129 +++++ .../org-switching/useOrgSwitchingContext.ts | 8 + .../org-switching/useOrgSwitchingFlowState.ts | 43 ++ .../react/src/context/slash-id-context.tsx | 32 +- packages/react/src/dev.tsx | 60 ++- pnpm-lock.yaml | 22 +- 8 files changed, 733 insertions(+), 34 deletions(-) create mode 100644 packages/react/src/components/form/org-switching/org-switching-flow.ts create mode 100644 packages/react/src/components/form/org-switching/org-switching-form.tsx create mode 100644 packages/react/src/components/form/org-switching/useOrgSwitchingContext.ts create mode 100644 packages/react/src/components/form/org-switching/useOrgSwitchingFlowState.ts diff --git a/packages/react/package.json b/packages/react/package.json index ec3d2493..04e365c6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -60,7 +60,7 @@ }, "devDependencies": { "@faker-js/faker": "^8.0.2", - "@slashid/slashid": "3.28.0", + "@slashid/slashid": "3.28.1-hrd-console.0", "@storybook/addon-essentials": "7.6.19", "@storybook/addon-interactions": "7.4.0", "@storybook/addon-links": "7.4.0", diff --git a/packages/react/src/components/form/org-switching/org-switching-flow.ts b/packages/react/src/components/form/org-switching/org-switching-flow.ts new file mode 100644 index 00000000..ba9bc19e --- /dev/null +++ b/packages/react/src/components/form/org-switching/org-switching-flow.ts @@ -0,0 +1,471 @@ +import { Utils, Errors, User, ReachablePersonHandle } from "@slashid/slashid"; +import { + Cancel, + LogIn, + LoginConfiguration, + Retry, + MFA, + LoginOptions, + Recover, + RetryPolicy, +} from "../../../domain/types"; +import { ensureError } from "../../../domain/errors"; +import { isFactorRecoverable } from "../../../domain/handles"; +import { StoreRecoveryCodesState } from "../store-recovery-codes"; + +export type AuthnContext = { + config: LoginConfiguration; + options?: LoginOptions; + attempt: number; +}; +export interface InitialState { + status: "initial"; + logIn: (config: LoginConfiguration, options?: LoginOptions) => void; +} + +export interface AuthenticatingState { + status: "authenticating"; + context: AuthnContext; + retry: Retry; + cancel: Cancel; + updateContext: (context: AuthnContext) => void; + recover: () => void; + logIn: () => void; + setRecoveryCodes: (codes: string[]) => void; +} + +export interface SuccessState { + status: "success"; +} + +export interface ErrorState { + status: "error"; + context: AuthenticatingState["context"] & { + error: Error; + }; + retry: Retry; + cancel: Cancel; +} + +interface LoginEvent { + type: "sid_login"; + config: LoginConfiguration; + options?: LoginOptions; +} + +interface UpdateAuthnContextEvent { + type: "sid_login.update_context"; + context: AuthnContext; +} + +interface LoginSuccessEvent { + type: "sid_login.success"; + user: User; +} + +interface LoginErrorEvent { + type: "sid_login.error"; + error: Error; +} + +interface CancelEvent { + type: "sid_cancel"; +} + +interface RetryEvent { + type: "sid_retry"; + context: AuthenticatingState["context"]; + policy: RetryPolicy; +} + +interface InitEvent { + type: "sid_init"; +} + +interface StoreRecoveryCodesEvent { + type: "sid_storeRecoveryCodes"; + user: User; +} + +type Event = + | InitEvent + | LoginEvent + | UpdateAuthnContextEvent + | LoginSuccessEvent + | LoginErrorEvent + | RetryEvent + | CancelEvent + | StoreRecoveryCodesEvent; + +type FlowActions = { + // a function that will be called when the state is entered + entry?: () => void; +}; + +export type FlowState = FlowActions & + ( + | InitialState + | AuthenticatingState + | SuccessState + | ErrorState + | StoreRecoveryCodesState + ); + +type Observer = (state: FlowState, event: Event) => void; +type Send = (e: Event) => void; + +const createInitialState = (send: Send): InitialState => { + return { + status: "initial", + logIn: (config, options) => { + send({ type: "sid_login", config, options }); + }, + }; +}; + +const createAuthenticatingState = ( + send: Send, + context: AuthenticatingState["context"], + logInFn: LogIn | MFA, + recoverFn: Recover, + setRecoveryCodes: (codes: string[]) => void +): AuthenticatingState => { + function performLogin() { + return logInFn(context.config, context.options) + .then((user) => { + if (!user) { + send({ + type: "sid_login.error", + error: new Error("User not returned from /id"), + }); + return; + } + + if (context.config.factor.method === "totp") { + send({ + type: "sid_storeRecoveryCodes", + user, + }); + return; + } + send({ type: "sid_login.success", user }); + }) + .catch((error) => { + if (Errors.isFlowCancelledError(error)) { + return; + } + send({ type: "sid_login.error", error }); + }); + } + + async function recover() { + if ( + !context.config.handle?.type || + !Utils.isReachablePersonHandleType(context.config.handle.type) + ) { + send({ + type: "sid_login.error", + error: Errors.createSlashIDError({ + name: Errors.ERROR_NAMES.recoverNonReachableHandleType, + message: "Recovery requires a reachable handle type.", + context, + }), + }); + return; + } + + // not possible at the moment + if (!isFactorRecoverable(context.config.factor)) return; + + try { + return await recoverFn({ + factor: context.config.factor, + handle: context.config.handle as ReachablePersonHandle, + }); + + // recover does not authenticate on its own + // we still need to wait for login to complete + } catch (error) { + send({ type: "sid_login.error", error: ensureError(error) }); + } + } + + return { + status: "authenticating", + context: { + attempt: context.attempt, + config: context.config, + options: context.options, + }, + retry: (policy = "retry") => { + send({ type: "sid_retry", context, policy }); + }, + recover, + cancel: () => { + // Cancellation API needs to be exposed from the core SDK + send({ type: "sid_cancel" }); + }, + logIn: performLogin, + setRecoveryCodes, + updateContext: (newContext: AuthnContext) => { + send({ + type: "sid_login.update_context", + context: newContext, + }); + }, + }; +}; + +const createSuccessState = (): SuccessState => { + return { + status: "success", + }; +}; + +const createErrorState = ( + send: Send, + context: ErrorState["context"] +): ErrorState => { + return { + status: "error", + context, + retry: (policy = "retry") => { + send({ type: "sid_retry", context, policy }); + }, + cancel: () => { + send({ type: "sid_cancel" }); + }, + }; +}; + +const createStoreRecoveryCodesState = ( + send: Send, + recoveryCodes: string[], + user: User +): StoreRecoveryCodesState => { + return { + status: "storeRecoveryCodes", + context: { + recoveryCodes, + }, + confirm: () => { + send({ type: "sid_login.success", user }); + }, + }; +}; + +export type CreateFlowOptions = { + onSuccess?: (user: User) => void; + onError?: (error: Error, context: ErrorState["context"]) => void; +}; + +type HistoryEntry = { + state: FlowState; + event: Event; +}; + +/** + * Flow API factory function. + * + * Responsible for creating the flow state machine and providing the flow API. + * Internally it delegates event processing to the underlying state instances. + * + * When a transition is requested, this function will create a new state instance and notify subscribers. + * It will not do any checks to see if the transition is valid as it is the responsibility of the state instances. + * + * The Flow API is not symetric - external code can interact with it using the fields and methods of the state object. + * Internally the state object communicates with the flow using the send function, emitting events and letting the flow API orchestrate the state transitions. + * + * @param opts + * @returns + */ +export function createFlow(opts: CreateFlowOptions = {}) { + let logInFn: undefined | LogIn | MFA = undefined; + let recoverFn: undefined | Recover = undefined; + let cancelFn: undefined | Cancel = undefined; + let recoveryCodes: undefined | string[] = undefined; + let observers: Observer[] = []; + const send = (event: Event) => { + transition(event); + }; + + const setRecoveryCodes = (codes: string[]) => { + recoveryCodes = codes; + }; + + function setState(newState: FlowState, changeEvent: Event) { + state = newState; + + // keep a history of state transitions for debugging purposes + history.push({ state, event: changeEvent }); + + observers.forEach((o) => o(state, changeEvent)); + } + + let state: FlowState = createAuthenticatingState( + send, + { + config: { + factor: { + method: "email_link", + }, + }, + attempt: 0, + }, + logInFn!, + recoverFn!, + setRecoveryCodes + ); + + // each history entry contains a state and the event that triggered the transition to that state + const history: HistoryEntry[] = [{ state, event: { type: "sid_init" } }]; + + const { onSuccess, onError } = opts; + + // notify subscribers every time the state changes + + async function transition(e: Event) { + switch (e.type) { + // case "sid_login": + // // TODO replace with a check for ready state + // if (!logInFn || !recoverFn) break; + + // const loginContext: AuthenticatingState["context"] = { + // config: e.config, + // options: e.options, + // attempt: 1, + // }; + + // setState( + // createAuthenticatingState( + // send, + // loginContext, + // logInFn, + // recoverFn, + // setRecoveryCodes + // ), + // e + // ); + // break; + case "sid_storeRecoveryCodes": + // recovery codes are only stored on register authenticator + if (!recoveryCodes) { + send({ type: "sid_login.success", user: e.user }); + break; + } + + setState( + createStoreRecoveryCodesState(send, recoveryCodes!, e.user), + e + ); + break; + case "sid_login.update_context": + // TODO replace with a check for ready state + if (!logInFn || !recoverFn) break; + + const updatedContext: AuthnContext = { + config: e.context.config, + options: e.context.options, + attempt: e.context.attempt, + }; + + setState( + createAuthenticatingState( + send, + updatedContext, + logInFn, + recoverFn, + setRecoveryCodes + ), + e + ); + break; + case "sid_login.success": + // call onSuccess if present + if (typeof onSuccess === "function") { + onSuccess(e.user); + } + + setState(createSuccessState(), e); + break; + case "sid_login.error": + if (state.status !== "authenticating") break; + + const errorContext: ErrorState["context"] = { + ...state.context, + error: ensureError(e.error), + }; + + // call onError if present + if (typeof onError === "function") { + onError(e.error, errorContext); + } + + setState(createErrorState(send, errorContext), e); + break; + case "sid_retry": + // TODO replace with a check for ready state + if (!logInFn || !recoverFn) break; + + if (typeof cancelFn === "function") { + cancelFn(); + } + + const retryContext: AuthenticatingState["context"] = { + config: e.context.config, + options: e.context.options, + attempt: e.context.attempt + 1, + }; + + if (e.policy === "reset") { + setState(createInitialState(send), e); + break; + } + + setState( + createAuthenticatingState( + send, + retryContext, + logInFn, + recoverFn, + setRecoveryCodes + ), + e + ); + break; + case "sid_cancel": + if (typeof cancelFn === "function") { + cancelFn(); + } + + // setState(createInitialState(send), e); + break; + default: + break; + } + } + + // provide the flow API - interact with the flow using the state object, subscribe and unsubscribe to state changes + return { + history, + unsubscribe: (observer: Observer) => { + observers = observers.filter((ob) => ob === observer); + }, + subscribe: (observer: Observer) => { + observers.push(observer); + }, + // SDK is instantiated asynchronously, so we need to set the logIn and recover functions when it is ready + setLogIn: (fn: LogIn | MFA) => { + logInFn = fn; + }, + setRecover: (fn: Recover) => { + recoverFn = fn; + }, + setCancel: (fn: Cancel) => { + cancelFn = fn; + }, + state, + }; +} + +export type Flow = ReturnType; diff --git a/packages/react/src/components/form/org-switching/org-switching-form.tsx b/packages/react/src/components/form/org-switching/org-switching-form.tsx new file mode 100644 index 00000000..29de42f8 --- /dev/null +++ b/packages/react/src/components/form/org-switching/org-switching-form.tsx @@ -0,0 +1,129 @@ +import { clsx } from "clsx"; +import { useFlowState } from "../useFlowState"; +import { CreateFlowOptions } from "./org-switching-flow"; +// import { Initial } from "./initial"; +import { Authenticating } from "../authenticating"; +import { Error } from "../error"; +import { Success } from "../success"; +import { Footer } from "../footer"; +import { useConfiguration } from "../../../hooks/use-configuration"; +import { FormProvider } from "../../../context/form-context"; +import { useLastHandle } from "../../../hooks/use-last-handle"; +import { + ConfigurationOverrides, + ConfigurationOverridesProps, +} from "../../configuration-overrides"; +import { Handle, LoginOptions } from "../../../domain/types"; +import React, { useCallback } from "react"; +import { Slots, useSlots } from "../../slot"; +import { Factor } from "@slashid/slashid"; +import { PayloadOptions } from "../types"; +import { InternalFormContext } from "../internal-context"; +import { StoreRecoveryCodes } from "../store-recovery-codes"; +import { useLastFactor } from "../../../hooks/use-last-factor"; +import { Card } from "@slashid/react-primitives"; + +export type Props = ConfigurationOverridesProps & { + className?: string; + onSuccess?: CreateFlowOptions["onSuccess"]; + onError?: CreateFlowOptions["onError"]; + middleware?: LoginOptions["middleware"]; + children?: Slots< + "initial" | "authenticating" | "success" | "error" | "footer" + >; // TS does not enforce this, but it is used for documentation +}; + +/** + * Render a form that can be used to sign in or sign up a user. + * The form can be customized significantly using the built-in slots and CSS custom properties. + * Check the documentation for more information. + */ +export const Form = ({ + className, + onSuccess, + onError, + factors, + text, + middleware, + children, +}: Props) => { + const flowState = useFlowState({ onSuccess, onError }); + const { showBanner } = useConfiguration(); + const { lastHandle } = useLastHandle(); + const { lastFactor } = useLastFactor(); + const submitPayloadRef = React.useRef({ + handleType: undefined, + handleValue: undefined, + flag: undefined, + }); + const [selectedFactor, setSelectedFactor] = React.useState< + Factor | undefined + >(); + const { status } = flowState; + + const defaultSlots = React.useMemo(() => { + const slots = { + footer: showBanner ?