Ctrl K
ComunidadGuías…

src/services/emailOtp.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { AuthError, ConsoleClient } from '~/services/consoleClient'
import type { Database } from '~/types/database.types'
import dayjs from 'dayjs'
import { invokeCapgoApi } from '~/services/capgoApi'

const EMAIL_OTP_VALIDITY_WINDOW_HOURS = 1
const EMAIL_OTP_RATE_LIMIT_SECONDS_PATTERN = /after\s+(\d+)\s+seconds?/i

export type EmailOtpSendErrorKind = 'rate_limit' | 'captcha' | 'generic'

export interface ParsedEmailOtpSendError {
  kind: EmailOtpSendErrorKind
  waitSeconds?: number
}

export function parseEmailOtpSendError(
  error: Pick<AuthError, 'code' | 'message' | 'status'> | null | undefined,
): ParsedEmailOtpSendError | null {
  if (!error)
    return null

  const message = error.message ?? ''
  const lowerMessage = message.toLowerCase()
  const code = error.code ?? ''

  if (
    code === 'over_email_send_rate_limit'
    || error.status === 429
    || lowerMessage.includes('over_email_send_rate_limit')
  ) {
    const match = message.match(EMAIL_OTP_RATE_LIMIT_SECONDS_PATTERN)
    const waitSeconds = match ? Number.parseInt(match[1], 10) : undefined
    return {
      kind: 'rate_limit',
      waitSeconds: Number.isFinite(waitSeconds) ? waitSeconds : undefined,
    }
  }

  if (code === 'captcha_failed' || lowerMessage.includes('captcha'))
    return { kind: 'captcha' }

  return { kind: 'generic' }
}

export function getEmailOtpSendErrorMessage(
  parsed: ParsedEmailOtpSendError,
  t: (key: string, params?: Record<string, unknown>) => string,
): string {
  switch (parsed.kind) {
    case 'rate_limit':
      return parsed.waitSeconds
        ? t('email-otp-rate-limit-wait', { seconds: parsed.waitSeconds })
        : t('email-otp-rate-limit-wait-unknown')
    case 'captcha':
      return t('captcha-fail')
    default:
      return t('email-otp-send-failed')
  }
}

export function isRecentEmailOtpVerification(verifiedAt?: string | null) {
  if (!verifiedAt)
    return false

  return dayjs(verifiedAt).isAfter(dayjs().subtract(EMAIL_OTP_VALIDITY_WINDOW_HOURS, 'hour'))
}

export async function getRecentEmailOtpVerification(
  capgo: ConsoleClient<Database>,
  userId: string,
) {
  const { data, error } = await capgo
    .from('user_security')
    .select('email_otp_verified_at')
    .eq('user_id', userId)
    .maybeSingle()

  if (error)
    throw error

  return {
    verifiedAt: data?.email_otp_verified_at ?? null,
    isVerified: isRecentEmailOtpVerification(data?.email_otp_verified_at),
  }
}

export async function sendEmailOtpVerification(
  capgo: ConsoleClient<Database>,
  email: string,
  captchaToken?: string,
  purpose?: 'delete_account' | 'setup_2fa',
) {
  const emailRedirectTo = purpose
    ? new URL(`/?reason=${purpose}`, globalThis.location.origin).href
    : undefined

  return await capgo.auth.signInWithOtp({
    email,
    options: {
      shouldCreateUser: false,
      captchaToken: captchaToken || undefined,
      ...(emailRedirectTo ? { emailRedirectTo } : {}),
    },
  })
}

export async function verifyEmailOtp(
  capgo: ConsoleClient<Database>,
  token: string,
) {
  return await invokeCapgoApi('private/verify_email_otp', {
    client: capgo,
    body: { token: token.replaceAll(' ', '') },
  })
}