Seguridad

Sesiones y seguridad para un producto de IA. Dispositivos activos con cierre de sesión que anima la salida de las filas, cambio de contraseña con un medidor de fortaleza en vivo, configuración de dos factores con un código QR real, una verificación de 6 dígitos y códigos de recuperación descargables, passkeys mediante WebAuthn y eliminación de la cuenta tras una confirmación escrita.

Todo producto de IA acaba teniendo la misma página de seguridad: dónde has iniciado sesión, cómo inicias sesión y cómo salir. Security es esa página, construida sobre los grupos y filas del bloque Settings, con cada flujo conectado a callbacks asíncronos que tú controlas.

Active sessions enumera cada dispositivo con su navegador, sistema, ubicación y cuándo estuvo activo por última vez, en tiempo relativo claro. Este dispositivo está marcado y no se puede cerrar su sesión desde aquí. Cerrar la sesión de un dispositivo muestra el progreso en su botón, luego la fila se pliega y el foco pasa a la siguiente fila. Cerrar la sesión en todos los demás pregunta primero y nombra los dispositivos cuya sesión cerrará.

Change password comprueba mientras escribes: un medidor de fortaleza de cuatro pasos y una lista de lo que aún falta, sin bloquear nada de lo que escribes. Si tu servidor dice que la contraseña actual es incorrecta, el mensaje aparece bajo ese campo y el foco vuelve a él, conservando todo lo que escribiste. Puedes cerrar otras sesiones en el mismo paso.

La configuración de dos factores dibuja un código QR real y escaneable para el enlace otpauth que devuelve tu servidor, muestra la clave de configuración con un botón de copiar para cuando no se puede escanear y, en un móvil, ofrece abrir el enlace en una app de autenticación. El código de 6 dígitos se comprueba solo en cuanto está completo, sacude y se borra si es incorrecto, y luego muestra códigos de recuperación para copiar o descargar como archivo de texto. Desactivarlo pregunta primero.

Passkeys llama a tu flujo de WebAuthn y gestiona las respuestas del navegador: cerrar el aviso no añade nada y no muestra error, y un dispositivo que ya tiene una passkey lo indica. Eliminar la cuenta requiere la frase exacta, enumera lo que se va con ella y mantiene el diálogo abierto con tu mensaje si el servidor dice que no.

  1. Añade el registro Pro a components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Añade tu token

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  3. Añade el bloque

    pnpm dlx shadcn@latest add @hextaui-pro/security

Conéctalo a tu API

Pasa lo que tiene la cuenta ahora y un callback asíncrono para cada acción. Resuelve cuando el servidor termine y actualiza los datos, y cada parte muestra su propio progreso. Lanza un error para mostrar tu mensaje en su sitio, y lo que escribió la persona se conserva.

"use client"

import * as React from "react"
import { IconShieldLock } from "@tabler/icons-react"

import { SettingsSection, SettingsShell, type SettingsSectionItem } from "../settings/settings"
import { SecuritySettings, type SecurityPasskey, type SecuritySession } from "@/components/blocks/security/security"

const sections: SettingsSectionItem[] = [
  { id: "security", label: "Security", icon: <IconShieldLock />, keywords: ["password", "2fa", "sessions"] },
]

type Account = {
  email: string
  sessions: SecuritySession[]
  passkeys: SecurityPasskey[]
  passwordChangedAt: string | null
  twoFactor: boolean
}

async function call<T = void>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(path, { headers: { "content-type": "application/json" }, ...init })
  if (!response.ok) {
    const problem = await response.json().catch(() => null)
    throw new Error(problem?.detail ?? "Check your connection and try again.")
  }
  return response.status === 204 ? (undefined as T) : response.json()
}

export function SecurityPage({ initial }: { initial: Account }) {
  const [account, setAccount] = React.useState(initial)
  const refresh = async () => setAccount(await call<Account>("/api/account/security"))

  return (
    <SettingsShell sections={sections} className="h-svh">
      <SettingsSection id="security">
        <SecuritySettings
          email={account.email}
          sessions={account.sessions}
          onSignOutSession={async (id) => {
            await call(`/api/sessions/${id}`, { method: "DELETE" })
            await refresh()
          }}
          onSignOutOtherSessions={async () => {
            await call("/api/sessions/others", { method: "DELETE" })
            await refresh()
          }}
          passwordChangedAt={account.passwordChangedAt}
          onChangePassword={async (change) => {
            const response = await fetch("/api/password", {
              method: "PUT",
              body: JSON.stringify(change),
            })
            if (response.status === 403) return { currentPassword: "That password is incorrect." }
            if (!response.ok) throw new Error("Couldn’t change your password. Try again.")
            await refresh()
          }}
          twoFactorEnabled={account.twoFactor}
          onStartTwoFactor={() => call<{ uri: string }>("/api/2fa/setup", { method: "POST" })}
          onVerifyTwoFactor={async (code) => {
            const { recoveryCodes } = await call<{ recoveryCodes: string[] }>("/api/2fa/verify", {
              method: "POST",
              body: JSON.stringify({ code }),
            })
            await refresh()
            return recoveryCodes
          }}
          onDisableTwoFactor={async () => {
            await call("/api/2fa", { method: "DELETE" })
            await refresh()
          }}
          passkeys={account.passkeys}
          onAddPasskey={async () => {
            await registerPasskey()
            await refresh()
          }}
          onRemovePasskey={async (id) => {
            await call(`/api/passkeys/${id}`, { method: "DELETE" })
            await refresh()
          }}
          onDeleteAccount={async () => {
            await call("/api/account", { method: "DELETE" })
            window.location.assign("/")
          }}
        />
      </SettingsSection>
    </SettingsShell>
  )
}

async function registerPasskey() {
  const options = await call<PublicKeyCredentialCreationOptionsJSON>("/api/passkeys/options", {
    method: "POST",
  })
  const credential = (await navigator.credentials.create({
    publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
  })) as PublicKeyCredential
  await call("/api/passkeys", { method: "POST", body: JSON.stringify(credential.toJSON()) })
}

Usa las partes por separado

SecuritySessions, SecurityPasswordRow, SecurityTwoFactorRow, SecurityPasskeys y SecurityDeleteAccount funcionan cada uno por separado, así que puedes colocarlos en cualquier sección. Devuelve errores de campo desde onChangePassword para mostrarlos bajo el campo.

"use client"

import { SettingsGroup, SettingsSection } from "../settings/settings"
import {
  SecurityDeleteAccount,
  SecurityPasswordRow,
  SecuritySessions,
  SecurityTwoFactorRow,
  type SecuritySession,
} from "@/components/blocks/security/security"

export function AccountSection({
  email,
  sessions,
  twoFactor,
  refresh,
}: {
  email: string
  sessions: SecuritySession[]
  twoFactor: boolean
  refresh: () => Promise<void>
}) {
  return (
    <SettingsSection id="account">
      <SettingsGroup title="Sign-in">
        <SecurityPasswordRow
          email={email}
          onChangePassword={async ({ currentPassword, newPassword, signOutOthers }) => {
            const response = await fetch("/api/password", {
              method: "PUT",
              body: JSON.stringify({ currentPassword, newPassword, signOutOthers }),
            })
            if (response.status === 403) return { currentPassword: "That password is incorrect." }
            if (response.status === 422) return { newPassword: "That password showed up in a data breach." }
            if (!response.ok) throw new Error("Couldn’t change your password. Try again.")
          }}
        />
        <SecurityTwoFactorRow
          email={email}
          enabled={twoFactor}
          onStart={() => fetch("/api/2fa/setup", { method: "POST" }).then((response) => response.json())}
          onVerify={async (code) => {
            const response = await fetch("/api/2fa/verify", { method: "POST", body: JSON.stringify({ code }) })
            if (!response.ok) throw new Error("That code didn’t work. Try the newest one.")
            const { recoveryCodes } = await response.json()
            await refresh()
            return recoveryCodes
          }}
          onDisable={() => fetch("/api/2fa", { method: "DELETE" }).then(refresh)}
        />
      </SettingsGroup>
      <SecuritySessions
        title="Where you’re signed in"
        sessions={sessions}
        onSignOut={(id) => fetch(`/api/sessions/${id}`, { method: "DELETE" }).then(refresh)}
        onSignOutOthers={() => fetch("/api/sessions/others", { method: "DELETE" }).then(refresh)}
      />
      <SecurityDeleteAccount
        email={email}
        consequences={["Your workspace and its 3 projects", "Your Team plan, canceled right away"]}
        onDelete={async () => {
          const response = await fetch("/api/account", { method: "DELETE" })
          if (!response.ok) throw new Error("Couldn’t delete your account. Nothing was deleted.")
          window.location.assign("/")
        }}
      />
    </SettingsSection>
  )
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
SecuritySettingsTodo lo de abajo en una sola llamada. Cada parte aparece cuando pasas los datos y callbacks que necesita.
SecuritySessionsUn grupo que enumera los dispositivos con sesión iniciada, con cierre de sesión por dispositivo y para todas las demás sesiones.
SecurityPasswordRowUna fila que abre el diálogo de cambio de contraseña.
SecurityTwoFactorRowUna fila con el estado de dos factores, el diálogo de configuración y la confirmación de desactivación.
SecurityPasskeysUn grupo que enumera las passkeys, con añadir y quitar.
SecurityDeleteAccountUn grupo con la fila de eliminar cuenta y su confirmación escrita.
measurePasswordLa comprobación de fortaleza que usa el diálogo de contraseña, para tus propios formularios.
PropTipoPredeterminado
emailEl correo de la cuenta. Se usa en la frase de eliminación, la pista del gestor de contraseñas y el archivo de códigos de recuperación.
string–
sessions{ id, browser, os, device?, location?, lastActive, current? }. device es "desktop", "laptop", "phone" o "tablet".
SecuritySession[]–
onSignOutSessionCierra una sesión. Quítala de sessions cuando se resuelva y la fila se pliega.
(id: string) => Promise<void>–
onSignOutOtherSessionsCierra todas las sesiones excepto la actual. Se ejecuta después de que la persona confirme.
() => Promise<void>–
passwordChangedAtSe muestra como Last changed bajo Password.
Date | string | number | null–
onChangePasswordDevuelve { currentPassword } o { newPassword } para mostrar un mensaje bajo ese campo, o lanza un error para mostrarlo sobre los botones.
({ currentPassword, newPassword, signOutOthers }) => Promise<void | errors>–
twoFactorEnabledSi los dos factores están activados.
booleanfalse
onStartTwoFactorCrea un secreto pendiente cuando se abre el diálogo de configuración. uri es el enlace otpauth dibujado como código QR; secret es por defecto el que está en uri.
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifyTwoFactorComprueba el código de 6 dígitos y activa los dos factores. Devuelve códigos de recuperación para mostrarlos, o lanza un error si el código es incorrecto.
(code: string) => Promise<void | string[]>–
onDisableTwoFactorDesactiva los dos factores. Se ejecuta después de que la persona confirme.
() => Promise<void>–
passkeys{ id, name, createdAt, lastUsed? }.
SecurityPasskey[]–
onAddPasskeyEjecuta tu registro de WebAuthn. Un NotAllowedError o AbortError cuenta como cancelado y no muestra nada.
() => Promise<void>–
onRemovePasskeyQuita una passkey. Se ejecuta después de que la persona confirme.
(id: string) => Promise<void>–
passkeysSupportedSustituye la comprobación de compatibilidad con WebAuthn.
booleandetected
onDeleteAccountElimina la cuenta. Se ejecuta después de que la persona escriba la frase. Lanza un error para mantener el diálogo abierto con tu mensaje.
() => Promise<void>–
deleteConsequencesLo que se elimina, enumerado en la confirmación.
ReactNode[]–

SecuritySessions

También acepta todas las props de SettingsGroup.

PropTipoPredeterminado
sessionsLos dispositivos con sesión iniciada.
SecuritySession[]–
onSignOutCierra una sesión.
(id: string) => Promise<void>–
onSignOutOthersCierra todas las demás sesiones.
() => Promise<void>–
titleEl encabezado del grupo.
ReactNode"Active sessions"
PropTipoPredeterminado
emailSe rellena en un campo de nombre de usuario oculto para que los gestores de contraseñas actualicen el inicio de sesión correcto.
string–
changedAtCuándo cambió la contraseña por última vez.
Date | string | number | null–
onChangePasswordIgual que en SecuritySettings.
(change) => Promise<void | errors>–
PropTipoPredeterminado
emailSe nombra en la confirmación y en el archivo de códigos de recuperación.
string–
enabledSi los dos factores están activados.
boolean–
onStartIgual que onStartTwoFactor.
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifyIgual que onVerifyTwoFactor.
(code: string) => Promise<void | string[]>–
onDisableIgual que onDisableTwoFactor.
() => Promise<void>–

SecurityPasskeys

También acepta todas las props de SettingsGroup.

PropTipoPredeterminado
passkeysLas passkeys guardadas.
SecurityPasskey[]–
onAddEjecuta tu registro de WebAuthn.
() => Promise<void>–
onRemoveQuita una passkey.
(id: string) => Promise<void>–
supportedSi este navegador puede crear passkeys.
booleandetected

SecurityDeleteAccount

También acepta todas las props de SettingsGroup.

PropTipoPredeterminado
emailLa frase de confirmación es delete seguido de esto.
string–
onDeleteElimina la cuenta.
() => Promise<void>–
consequencesLo que se elimina con ella.
ReactNode[]–
titleEl encabezado del grupo.
ReactNode"Danger zone"
KeyAcción
TabRecorre las filas y sus botones, y luego cada diálogo.
EnterEnvía el formulario de contraseña desde cualquier campo, y elimina la cuenta desde el campo de la frase cuando coincide.
EscapeCierra un diálogo, salvo mientras algo se está guardando.
0–9Rellena el código de verificación. Se comprueba solo cuando están los seis dígitos, y pegar un código también funciona.
  • Las acciones destructivas nunca reciben el foco primero. Las confirmaciones nombran lo que afectan, como los dispositivos cuya sesión se cierra o el correo que se elimina, y el campo de la frase de eliminación recibe el foco en lugar del botón.
  • Cuando una fila se pliega, el foco pasa al botón de la fila siguiente, o a la anterior, para que nunca caiga a la página. Cerrar un diálogo devuelve el foco al botón que lo abrió, o a su sustituto cuando ese botón ya no existe.
  • Los cierres de sesión, las passkeys añadidas y quitadas y la fortaleza de la contraseña se anuncian de forma polite. Los errores de tus callbacks se anuncian como alertas y permanecen junto al campo o botón al que pertenecen.
  • Cada requisito de contraseña indica si se cumple, el código QR tiene una etiqueta de texto, y la clave de configuración y los códigos de recuperación son texto legible que puedes seleccionar.
  • Con movimiento reducido, las filas se desvanecen en lugar de plegarse y los cambios de paso no se deslizan. En modo de alto contraste, el código QR y el medidor de fortaleza conservan sus formas.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Security. Cada uno se instala por separado.

Código

12 archivos, añadidos a components/blocks/security.