Source

lib/flow-api/State.ts

import { Hanko } from "../../Hanko";
import { Actions, Payloads, StateName } from "./types/state";
import { Input } from "./types/input";
import { FlowError } from "./types/flowError";
import { Action as ActionType } from "./types/action";
import { AnyState, FlowName, FlowResponse } from "./types/flow";
import { autoSteps } from "./auto-steps";
import { passkeyAutofillActivationHandlers } from "./passkey-autofill-activation";

export type AutoSteppedStates = keyof typeof autoSteps;

export type PasskeyAutofillStates =
  keyof typeof passkeyAutofillActivationHandlers;

export type AutoStepExclusion = AutoSteppedStates[] | "all";

export type ActionMap<TState extends StateName> = {
  [K in keyof Actions[TState]]: Action<
    Actions[TState][K] extends ActionType<infer TInputs> ? TInputs : never
  >;
};

export type ActionInfo = {
  name: string;
  relatedStateName: StateName;
};

export interface StateInitConfig {
  dispatchAfterStateChangeEvent?: boolean;
  excludeAutoSteps?: AutoStepExclusion;
  previousAction?: ActionInfo;
  isCached?: boolean;
  cacheKey?: string;
}

export type StateCreateConfig = Pick<
  StateInitConfig,
  "dispatchAfterStateChangeEvent" | "excludeAutoSteps" | "cacheKey"
> & {
  loadFromCache?: boolean;
};

export type ActionRunConfig = Pick<
  StateInitConfig,
  "dispatchAfterStateChangeEvent"
>;

type SerializedState = FlowResponse<any> & {
  flow_name: FlowName;
  previous_action?: ActionInfo;
  is_cached?: boolean;
};

type ExtractInputValues<TInputs> = {
  [K in keyof TInputs]: TInputs[K] extends Input<infer TValue> ? TValue : never;
};

/**
 * Represents a state in a flow with associated actions and properties.
 * @template TState - The specific state name type.
 * @constructor
 * @param {Hanko} hanko - The Hanko instance for API interactions.
 * @param {FlowName} flowName - The name of the flow this state belongs to.
 * @param {FlowResponse<TState>} response - The flow response containing state data.
 * @param {StateInitConfig} [options={}] - Configuration options for state initialization.
 * @category SDK
 * @subcategory FlowAPI
 */
export class State<TState extends StateName = StateName> {
  public readonly name: TState;
  public readonly flowName: FlowName;
  public error?: FlowError;
  public readonly payload?: Payloads[TState];
  public readonly actions: ActionMap<TState>;
  public readonly csrfToken: string;
  public readonly status: number;
  public readonly previousAction?: ActionInfo;
  public readonly isCached: boolean;
  public readonly cacheKey: string;
  public readonly hanko: Hanko;
  public invokedAction?: ActionInfo;
  public readonly excludeAutoSteps: AutoStepExclusion;

  public readonly autoStep?: TState extends AutoSteppedStates
    ? () => Promise<AnyState>
    : never;
  public readonly passkeyAutofillActivation: TState extends PasskeyAutofillStates
    ? () => Promise<void>
    : never;

  /**
   * Constructs a new State instance.
   * @param {Hanko} hanko - The Hanko instance for API interactions.
   * @param {FlowName} flowName - The name of the flow this state belongs to.
   * @param {FlowResponse<TState>} response - The flow response containing state data.
   * @param {StateInitConfig} [options={}] - Configuration options for state initialization.
   */
  constructor(
    hanko: Hanko,
    flowName: FlowName,
    response: FlowResponse<TState>,
    options: StateInitConfig = {},
  ) {
    this.flowName = flowName;
    this.name = response.name;
    this.error = response.error;
    this.payload = response.payload;
    this.csrfToken = response.csrf_token;
    this.status = response.status;
    this.hanko = hanko;
    this.actions = this.buildActionMap(response.actions);

    if (this.name in autoSteps) {
      const handler = autoSteps[this.name as AutoSteppedStates];
      (this.autoStep as () => Promise<AnyState>) = () => handler(this as any);
    }

    if (this.name in passkeyAutofillActivationHandlers) {
      const handler =
        passkeyAutofillActivationHandlers[this.name as PasskeyAutofillStates];
      (this.passkeyAutofillActivation as () => Promise<void>) = () =>
        handler(this as any);
    }

    const {
      dispatchAfterStateChangeEvent = true,
      excludeAutoSteps = null,
      previousAction = null,
      isCached = false,
      cacheKey = "hanko-flow-state",
    } = options;

    this.excludeAutoSteps = excludeAutoSteps;
    this.previousAction = previousAction;
    this.isCached = isCached;
    this.cacheKey = cacheKey;

    if (dispatchAfterStateChangeEvent) {
      this.dispatchAfterStateChangeEvent();
    }
  }

  /**
   * Builds the action map for this state, wrapping it in a Proxy to handle undefined actions.
   * @param {Actions} actions - The actions available in this state.
   * @returns {ActionMap<TState>} The action map for the state.
   * @private
   */
  private buildActionMap(actions: Actions[TState]): ActionMap<TState> {
    const actionMap: Partial<ActionMap<TState>> = {};

    Object.keys(actions).forEach((actionName) => {
      const key = actionName as keyof Actions[TState];
      const action = actions[key] as ActionType<any>;

      actionMap[key] = new Action(action, this);
    });

    // Return a Proxy that handles missing keys
    return new Proxy(actionMap as ActionMap<TState>, {
      get: (target: ActionMap<TState>, prop: string | symbol): Action<any> => {
        if (prop in target) {
          return target[prop as keyof ActionMap<TState>];
        }

        const actionName = typeof prop === "string" ? prop : prop.toString();

        return Action.createDisabled(actionName, this);
      },
    });
  }

  /**
   * Dispatches an event after the state has changed.
   */
  public dispatchAfterStateChangeEvent() {
    this.hanko.relay.dispatchAfterStateChangeEvent({
      state: this as AnyState,
    });
  }

  /**
   * Serializes the current state into a storable format.
   * @returns {SerializedState} The serialized state object.
   */
  public serialize(): SerializedState {
    return {
      flow_name: this.flowName,
      name: this.name,
      error: this.error,
      payload: this.payload,
      csrf_token: this.csrfToken,
      status: this.status,
      previous_action: this.previousAction,
      actions: Object.fromEntries(
        (Object.entries(this.actions) as [string, Action<any>][]).map(
          ([name, action]) => [
            name,
            {
              action: action.name,
              href: action.href,
              inputs: action.inputs,
              description: null,
            },
          ],
        ),
      ),
    };
  }

  /**
   * Saves the current state to localStorage.
   * @returns {void}
   */
  public saveToLocalStorage(): void {
    localStorage.setItem(
      this.cacheKey,
      JSON.stringify({ ...this.serialize(), is_cached: true }),
    );
  }

  /**
   * Removes the current state from localStorage.
   * @returns {void}
   */
  public removeFromLocalStorage(): void {
    localStorage.removeItem(this.cacheKey);
  }

  /**
   * Initializes a flow state, processing auto-steps if applicable.
   * @param {Hanko} hanko - The Hanko instance for API interactions.
   * @param {FlowName} flowName - The name of the flow.
   * @param {FlowResponse<any>} response - The initial flow response.
   * @param {StateInitConfig} [options={}] - Configuration options.
   * @param {boolean} [options.dispatchAfterStateChangeEvent=true] - Whether to dispatch an event after state change.
   * @param {AutoStepExclusion} [options.excludeAutoSteps=null] - States to exclude from auto-step processing, or "all".
   * @param {ActionInfo} [options.previousAction=null] - Information about the previous action.
   * @param {boolean} [options.isCached=false] - Whether the state is loaded from cache.
   * @param {string} [options.cacheKey="hanko-flow-state"] - Key for localStorage caching.
   * @returns {Promise<AnyState>} A promise resolving to the initialized state.
   */
  public static async initializeFlowState(
    hanko: Hanko,
    flowName: FlowName,
    response: FlowResponse<any>,
    options: StateInitConfig = {},
  ): Promise<AnyState> {
    let state = new State(hanko, flowName, response, options);

    if (state.excludeAutoSteps != "all") {
      while (
        state &&
        state.autoStep &&
        !state.excludeAutoSteps?.includes(state.name)
      ) {
        const nextState = await state.autoStep();
        if (nextState.name != state.name) {
          state = nextState;
        } else {
          return nextState;
        }
      }
    }

    return state;
  }

  /**
   * Retrieves and parses state data from localStorage.
   * @param {string} cacheKey - The key used to store the state in localStorage.
   * @returns {SerializedState | undefined} The parsed serialized state, or undefined if not found or invalid.
   */
  public static readFromLocalStorage(
    cacheKey: string,
  ): SerializedState | undefined {
    const raw = localStorage.getItem(cacheKey);
    if (raw) {
      try {
        return JSON.parse(raw) as SerializedState;
      } catch {
        return undefined;
      }
    }
  }

  /**
   * Creates a new state instance, using cached or fetched data.
   * @param {Hanko} hanko - The Hanko instance for API interactions.
   * @param {FlowName} flowName - The name of the flow.
   * @param {StateCreateConfig} [config={}] - Configuration options.
   * @param {boolean} [config.dispatchAfterStateChangeEvent=true] - Whether to dispatch an event after state change.
   * @param {AutoStepExclusion} [config.excludeAutoSteps=null] - States to exclude from auto-step processing, or "all".
   * @param {string} [config.cacheKey="hanko-flow-state"] - Key for localStorage caching.
   * @param {boolean} [config.loadFromCache=true] - Whether to attempt loading from cache.
   * @returns {Promise<AnyState>} A promise resolving to the created state.
   */
  public static async create(
    hanko: Hanko,
    flowName: FlowName,
    config: StateCreateConfig = {},
  ): Promise<AnyState> {
    const { cacheKey = "hanko-flow-state", loadFromCache = true } = config;
    if (loadFromCache) {
      const cachedState = State.readFromLocalStorage(cacheKey);
      if (cachedState) {
        return State.deserialize(hanko, cachedState, {
          ...config,
          cacheKey,
        });
      }
    }

    const newState = await State.fetchState(hanko, `/${flowName}`);
    return State.initializeFlowState(hanko, flowName, newState, {
      ...config,
      cacheKey,
    });
  }

  /**
   * Deserializes a state from a serialized state object.
   * @param {Hanko} hanko - The Hanko instance for API interactions.
   * @param {SerializedState} serializedState - The serialized state data.
   * @param {StateCreateConfig} [config={}] - Configuration options.
   * @param {boolean} [config.dispatchAfterStateChangeEvent=true] - Whether to dispatch an event after state change.
   * @param {AutoStepExclusion} [config.excludeAutoSteps=null] - States to exclude from auto-step processing, or "all".
   * @param {string} [config.cacheKey="hanko-flow-state"] - Key for localStorage caching.
   * @param {boolean} [config.loadFromCache=true] - Whether to attempt loading from cache.
   * @returns {Promise<AnyState>} A promise resolving to the deserialized state.
   */
  public static async deserialize(
    hanko: Hanko,
    serializedState: SerializedState,
    config: StateCreateConfig = {},
  ): Promise<AnyState> {
    return State.initializeFlowState(
      hanko,
      serializedState.flow_name,
      serializedState,
      {
        ...config,
        previousAction: serializedState.previous_action,
        isCached: serializedState.is_cached,
      },
    );
  }

  /**
   * Fetches state data from the server.
   * @param {Hanko} hanko - The Hanko instance for API interactions.
   * @param {string} href - The endpoint to fetch from.
   * @param {any} [body] - Optional request body.
   * @returns {Promise<FlowResponse<any>>} A promise resolving to the flow response.
   */
  static async fetchState(
    hanko: Hanko,
    href: string,
    body?: any,
  ): Promise<FlowResponse<any>> {
    try {
      const response = await hanko.client.post(href, body);
      return response.json();
    } catch (error) {
      return State.createErrorResponse(error);
    }
  }

  /**
   * Creates an error flow response.
   * @param {FlowError} error - The error to include in the response.
   * @returns {FlowResponse<"error">} A flow response with error details.
   * @private
   */
  private static createErrorResponse(error: FlowError): FlowResponse<"error"> {
    return {
      actions: null,
      csrf_token: "",
      name: "error",
      payload: null,
      status: 0,
      error,
    };
  }
}

/**
 * Represents an actionable operation within a state.
 * @template TInputs - The type of inputs required for the action.
 * @param {ActionType<TInputs>} action - The action type definition.
 * @param {State} parentState - The state this action belongs to.
 * @param {boolean} [enabled=true] - Whether the action is enabled.
 * @category SDK
 * @subcategory FlowAPI
 */
export class Action<TInputs> {
  public readonly enabled: boolean;
  public readonly href: string;
  public readonly name: string;
  public readonly inputs: TInputs;
  private readonly parentState: State;

  /**
   * Constructs a new Action instance.
   * @param {ActionType<TInputs>} action - The action type definition.
   * @param {State} parentState - The state this action belongs to.
   * @param {boolean} [enabled=true] - Whether the action is enabled.
   */
  constructor(
    action: ActionType<TInputs>,
    parentState: State,
    enabled: boolean = true,
  ) {
    this.enabled = enabled;
    this.href = action.href;
    this.name = action.action;
    this.inputs = action.inputs;
    this.parentState = parentState;
  }

  /**
   * Creates a disabled action instance.
   * @template TInputs - The type of inputs (inferred as empty).
   * @param {string} name - The name of the action.
   * @param {State} parentState - The state this action belongs to.
   * @returns {Action<TInputs>} A disabled action instance.
   */
  static createDisabled<TInputs>(
    name: string,
    parentState: State,
  ): Action<TInputs> {
    return new Action(
      {
        action: name,
        href: "", // No valid href since it’s disabled
        inputs: {} as TInputs,
        description: "Disabled action",
      },
      parentState,
      false,
    );
  }

  /**
   * Executes the action, transitioning to a new state.
   * @param {ExtractInputValues<TInputs>} [inputValues=null] - Values for the action's inputs.
   * @param {ActionRunConfig} [config={}] - Configuration options.
   * @param {boolean} [config.dispatchAfterStateChangeEvent=true] - Whether to dispatch an event after state change.
   * @returns {Promise<AnyState>} A promise resolving to the next state.
   * @throws {FlowError} If the action is disabled or already invoked.
   */
  async run(
    inputValues: ExtractInputValues<TInputs> = null,
    config: ActionRunConfig = {},
  ): Promise<AnyState> {
    const {
      name,
      hanko,
      flowName,
      csrfToken,
      invokedAction,
      excludeAutoSteps,
      cacheKey,
    } = this.parentState;
    const { dispatchAfterStateChangeEvent = true } = config;

    if (!this.enabled) {
      throw new Error(
        `Action '${this.name}' is not enabled in state '${name}'`,
      );
    }

    if (invokedAction) {
      throw new Error(
        `An action '${invokedAction.name}' has already been invoked on state '${invokedAction.relatedStateName}'. No further actions can be run.`,
      );
    }

    this.parentState.invokedAction = {
      name: this.name,
      relatedStateName: name,
    };

    hanko.relay.dispatchBeforeStateChangeEvent({
      state: this.parentState as AnyState,
    });

    // Extract default values from this.inputs
    const defaultValues = Object.keys(this.inputs).reduce(
      (acc, key) => {
        const input = (this.inputs as any)[key] as Input<any>;
        if (input.value !== undefined) {
          acc[key] = input.value;
        }
        return acc;
      },
      {} as Record<string, any>,
    );

    // Merge defaults with user-provided inputs
    const mergedInputData = {
      ...defaultValues,
      ...inputValues,
    };

    const requestBody = {
      input_data: mergedInputData,
      csrf_token: csrfToken,
    };

    const response = await State.fetchState(hanko, this.href, requestBody);

    this.parentState.removeFromLocalStorage();

    return State.initializeFlowState(hanko, flowName, response, {
      dispatchAfterStateChangeEvent,
      excludeAutoSteps,
      previousAction: invokedAction,
      cacheKey,
    });
  }
}