Подтверждение действий пользователя

Принцип работы

В рамках задачи STUDWORK-4294 был переопределен принцип работы подтверждений пользователя, делая их унифицированными.

  1. Фронтенд делает запрос к апи
  2. Если пользователю требуется подтвердить действие, апи отдает ошибку с кодом 449 (AxiosError<ConfirmOptions>)
Подробно об ответах апи (`registry/repositories/user/confirm.ts`)
import { useRegistryWrapper, type RegistryKey } from '#sf/modules/registry';

import type { UserConfirmationType } from '#kb/consts/user-confirmation-type';
import type { Transport } from '#kb/consts/transports';
import type { Implements } from '#sf/types/utils';

export type AvailableTransport = Exclude<Transport, Transport.FLASHCALL>;

export interface TransportOptions {
  /**
   * id транспорта
   */
  transport: AvailableTransport;

  /**
   * Параметры ввода кода
   */
  attempts: {
    /**
     * Количество оставшихся попыток ввода кода
     */
    remaining: number;

    /**
     * Дата в ISO следующей попытки
     */
    next: string;
  };

  /**
   * Дополнительная информация
   */
  data?: unknown;

  /**
   * Правильный код подтверждения (only dev)
   */
  code?: string;
}

export interface ResendTransportOptions extends TransportOptions {
  /**
   * Параметры переотправки кода
   */
  codes: {
    /**
     * Количество оставшихся попыток переотправок
     */
    remaining: number;

    /**
     * Дата в ISO следующей попытки
     */
    next: string;
  };

  /**
   * Дата последнего отправленного кода в ISO
   */
  lastCodeAt: string | null;
}

export interface PhoneTransportOptions extends ResendTransportOptions {
  data: {
    /**
     * Номер телефона получателя
     */
    phone: string;
  };
}

export interface EmailTransportOptions extends ResendTransportOptions {
  data: {
    /**
     * Email получателя
     */
    email: string;
  };
}

export interface TotpTransportOptions extends TransportOptions {}

type TransportMap = {
  [Key in AvailableTransport]: TransportOptions;
};
/**
 * Список возможных транспортов с сопоставленными опциями
 */
export interface AvailableTransports {
  [Transport.EMAIL]: EmailTransportOptions;
  [Transport.SMS]: PhoneTransportOptions;
  [Transport.TOTP]: TotpTransportOptions;
  [Transport.CASCADE_PHONE]: PhoneTransportOptions;
}

type A = Implements<TransportMap, AvailableTransports>;

/**
 * Хелпер для возвращания транспортов по его опциям
 */
export type TransportWithOptions<T extends TransportOptions> = {
  [K in keyof AvailableTransports]: AvailableTransports[K] extends T
    ? K
    : never;
}[keyof AvailableTransports];

/**
 * Настройки подтверждения конкретного действия
 */
export interface ConfirmOptions {
  /**
   * Приоритетный/первый `transport` для отображения юзеру
   *
   * Приходит с бекенда т.к. для части подтверждений бек должен сперва сделать действие, например отправить смс
   */
  activeTransport: AvailableTransport;

  /**
   * Название действия для подтверждения (используется лишь для обратной передачи бекенду)
   */
  type: UserConfirmationType;

  /**
   * Идентификатор подтверждения
   */
  token: string;

  /**
   * Возможные варианты подтверждения
   */
  transports: Array<AvailableTransports[keyof AvailableTransports]>;
}

export interface ConfirmResponse {
  confirmation: ConfirmOptions;
}

interface GetContactsParams {
  type: UserConfirmationType;
}

export interface IUserConfirmRepo {
  /**
   * Попытка ввода кода подтверждения
   *
   * @throws {AxiosError\<ConfirmResponse>} 449 - Кончились попытки ввода кода
   */
  confirm: <T>(token: string, transport: Transport, code: string) => Promise<T>;

  /**
   * Переотправка (или смена транспорта)
   *
   * @throws {AxiosError\<ConfirmResponse>} 449 - Кончились попытки отправки
   */
  resend: (token: string, transport: Transport) => Promise<ConfirmResponse>;

  get: (params: Partial<GetContactsParams>) => Promise<{
    confirmations: ConfirmOptions[];
  }>;
}

export const userConfirmRepoRegistryKey =
  'user-confirm-repo-registry-key' as unknown as RegistryKey<IUserConfirmRepo>;

export default useRegistryWrapper(userConfirmRepoRegistryKey);
  1. Фронт отображает(или через форму, или модалкой) первый способ (activeTransport) пользователю и если есть альтернативы, дает возможность выбора иного способа
    • Юзер может выбрать иной способ, для этого фронт делает запрос resend с параметром нового транспорта
  2. После успешного подтверждения, бекенд отдает изначальный ответ из шага 1
    • Бекенд так же может вернуть 449 но с другим token, что значит, что необходимо второе подтверждение и мы возвращаемся к шагу 2

Добавление транспортов

  1. Добавить новый транспорт в kb(shared/data/transports.yml)
  2. Добавить в #sf/components/user/confirm-2fa/config.ts в transportConfigs конфиг этого транспорта с указанием компонента (подробнее далее) (если не добавить новый транспорт, typescript будет выдавать ошибку)
  3. Написать компонент для транспорта

Компонент транспорта

Каждый компонент может эмитить 2 события, success в случае успешного подтверждения, exceed если кончились попытки ввода и nextConfirmation если изменилось подтверждение (сменился идентификатор)

бОльшую часть логики берут на себя composable useConfirmApi(отвечает за апи запросы), useOtpInput(за работу с инпутом ввода кодов и его состояния), useResend(если код можно переотправить, то за текст и состояние кнопки переотправки)

Наиболее типичным (с переотправкой) компонентом является email-confirmation

Логика работы экранов транспортов

Ядром фронта для работы с подтверждениями является файл #sf/components/user/confirm2-fa/composables/change-transport.ts

Для смены транспортов необходимо использовать его, т.к. под капотом помимо смены компонента, он же производит запрос кода, если этого не было сделано