Segurança

Sessões e segurança para um produto de IA. Dispositivos ativos com encerramento de sessão que anima a saída das linhas, troca de senha com um medidor de força em tempo real, configuração de autenticação em duas etapas com um QR code real, uma verificação de 6 dígitos e códigos de recuperação para baixar, passkeys via WebAuthn e exclusão de conta protegida por uma confirmação digitada.

Todo produto de IA acaba com a mesma página de segurança: onde você está conectado, como você entra e como sair. O Security é essa página, construída sobre os grupos e as linhas do bloco Settings, com cada fluxo ligado a callbacks assíncronos que você controla.

Active sessions lista cada dispositivo com seu navegador, sistema, localização e quando esteve ativo pela última vez, em tempo relativo simples. Este dispositivo é marcado e não pode ser desconectado a partir daqui. Encerrar a sessão de um dispositivo mostra o progresso em seu botão, depois a linha se dobra e o foco vai para a próxima linha. Encerrar todas as outras pergunta antes e cita os dispositivos que serão desconectados.

Change password verifica enquanto você digita: um medidor de força de quatro etapas e uma lista do que ainda falta, sem bloquear nada do que você digita. Se o seu servidor disser que a senha atual está errada, a mensagem aparece sob esse campo e o foco volta a ele, com tudo o que você digitou preservado. Você pode encerrar as outras sessões na mesma etapa.

A configuração da autenticação em duas etapas desenha um QR code de verdade, escaneável, para o link otpauth que o seu servidor retorna, mostra a chave de configuração com um botão de copiar para quando escanear não é possível e, no celular, oferece abrir o link em um app autenticador. O código de 6 dígitos se verifica assim que está completo, sacode e se limpa se estiver errado e depois mostra os códigos de recuperação para copiar ou baixar como arquivo de texto. Desativar pergunta antes.

Passkeys chama o seu fluxo WebAuthn e trata as respostas do navegador: fechar o prompt não adiciona nada e não mostra erro, e um dispositivo que já tem uma passkey informa isso. Excluir a conta exige a frase exata, lista o que vai junto e mantém o diálogo aberto com a sua mensagem se o servidor disser não.

  1. Adicione o registro Pro ao components.json

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

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  3. Adicione o bloco

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

Conecte à sua API

Passe o que a conta tem agora e um callback assíncrono para cada ação. Resolva quando o servidor terminar e atualize os dados, e cada parte mostra seu próprio progresso. Lance um erro para mostrar a sua mensagem no lugar, e o que a pessoa digitou permanece.

"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()) })
}

Use as partes isoladamente

SecuritySessions, SecurityPasswordRow, SecurityTwoFactorRow, SecurityPasskeys e SecurityDeleteAccount funcionam cada um isoladamente, então você pode colocá-los em qualquer seção. Retorne erros de campo de onChangePassword para exibi-los sob o 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>
  )
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
SecuritySettingsTudo abaixo em uma só chamada. Cada parte aparece quando você passa os dados e os callbacks de que ela precisa.
SecuritySessionsUm grupo que lista os dispositivos conectados, com encerrar sessão por dispositivo e para todas as outras sessões.
SecurityPasswordRowUma linha que abre o diálogo de alterar senha.
SecurityTwoFactorRowUma linha com o status da autenticação em duas etapas, o diálogo de configuração e a confirmação para desativar.
SecurityPasskeysUm grupo que lista as passkeys, com adicionar e remover.
SecurityDeleteAccountUm grupo com a linha de excluir conta e sua confirmação digitada.
measurePasswordA verificação de força que o diálogo de senha usa, para os seus próprios formulários.
PropTipoPadrão
emailO e-mail da conta. Usado na frase de exclusão, na dica do gerenciador de senhas e no arquivo de códigos de recuperação.
string–
sessions{ id, browser, os, device?, location?, lastActive, current? }. device é "desktop", "laptop", "phone" ou "tablet".
SecuritySession[]–
onSignOutSessionEncerra uma sessão. Remova-a de sessions quando for resolvido e a linha se dobra.
(id: string) => Promise<void>–
onSignOutOtherSessionsEncerra todas as sessões, exceto a atual. Executa depois que a pessoa confirma.
() => Promise<void>–
passwordChangedAtExibido como Last changed sob Password.
Date | string | number | null–
onChangePasswordRetorne { currentPassword } ou { newPassword } para mostrar uma mensagem sob esse campo, ou lance um erro para mostrá-la acima dos botões.
({ currentPassword, newPassword, signOutOthers }) => Promise<void | errors>–
twoFactorEnabledSe a autenticação em duas etapas está ativada.
booleanfalse
onStartTwoFactorCria um segredo pendente quando o diálogo de configuração abre. uri é o link otpauth desenhado como QR code; secret usa por padrão o que está em uri.
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifyTwoFactorVerifica o código de 6 dígitos e ativa a autenticação em duas etapas. Retorne os códigos de recuperação para exibi-los, ou lance um erro se o código estiver errado.
(code: string) => Promise<void | string[]>–
onDisableTwoFactorDesativa a autenticação em duas etapas. Executa depois que a pessoa confirma.
() => Promise<void>–
passkeys{ id, name, createdAt, lastUsed? }.
SecurityPasskey[]–
onAddPasskeyExecuta o seu registro WebAuthn. Um NotAllowedError ou AbortError conta como cancelado e não mostra nada.
() => Promise<void>–
onRemovePasskeyRemove uma passkey. Executa depois que a pessoa confirma.
(id: string) => Promise<void>–
passkeysSupportedSubstitui a verificação de suporte a WebAuthn.
booleandetected
onDeleteAccountExclui a conta. Executa depois que a pessoa digita a frase. Lance um erro para manter o diálogo aberto com a sua mensagem.
() => Promise<void>–
deleteConsequencesO que é excluído, listado na confirmação.
ReactNode[]–

SecuritySessions

Também aceita todas as props do SettingsGroup.

PropTipoPadrão
sessionsOs dispositivos conectados.
SecuritySession[]–
onSignOutEncerra uma sessão.
(id: string) => Promise<void>–
onSignOutOthersEncerra todas as outras sessões.
() => Promise<void>–
titleO título do grupo.
ReactNode"Active sessions"
PropTipoPadrão
emailPreenchido em um campo de nome de usuário oculto para que os gerenciadores de senhas atualizem o login correto.
string–
changedAtQuando a senha foi alterada pela última vez.
Date | string | number | null–
onChangePasswordIgual ao do SecuritySettings.
(change) => Promise<void | errors>–
PropTipoPadrão
emailCitado na confirmação e no arquivo de códigos de recuperação.
string–
enabledSe a autenticação em duas etapas está ativada.
boolean–
onStartIgual a onStartTwoFactor.
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifyIgual a onVerifyTwoFactor.
(code: string) => Promise<void | string[]>–
onDisableIgual a onDisableTwoFactor.
() => Promise<void>–

SecurityPasskeys

Também aceita todas as props do SettingsGroup.

PropTipoPadrão
passkeysAs passkeys salvas.
SecurityPasskey[]–
onAddExecuta o seu registro WebAuthn.
() => Promise<void>–
onRemoveRemove uma passkey.
(id: string) => Promise<void>–
supportedSe este navegador pode criar passkeys.
booleandetected

SecurityDeleteAccount

Também aceita todas as props do SettingsGroup.

PropTipoPadrão
emailA frase de confirmação é delete seguido disto.
string–
onDeleteExclui a conta.
() => Promise<void>–
consequencesO que é excluído junto.
ReactNode[]–
titleO título do grupo.
ReactNode"Danger zone"
TeclaAção
TabPercorre as linhas e seus botões, depois cada diálogo.
EnterEnvia o formulário de senha a partir de qualquer campo e exclui a conta a partir do campo da frase quando ela corresponde.
EscapeFecha um diálogo, exceto enquanto algo está sendo salvo.
0–9Preenche o código de verificação. Ele se verifica sozinho quando os seis dígitos estão preenchidos, e colar um código também funciona.
  • Ações destrutivas nunca recebem o foco primeiro. As confirmações citam o que afetam, como os dispositivos que serão desconectados ou o e-mail que será excluído, e o campo da frase de exclusão recebe o foco no lugar do botão.
  • Quando uma linha se dobra, o foco vai para o botão da próxima linha, ou da anterior, para nunca cair na página. Fechar um diálogo devolve o foco ao botão que o abriu, ou ao seu substituto quando esse botão não existe mais.
  • Os encerramentos de sessão, as passkeys adicionadas e removidas e a força da senha são anunciados de forma polite. Os erros dos seus callbacks são anunciados como alertas e permanecem ao lado do campo ou botão a que pertencem.
  • Cada requisito de senha informa se foi atendido, o QR code tem um rótulo de texto, e a chave de configuração e os códigos de recuperação são texto legível que você pode selecionar.
  • Com movimento reduzido, as linhas esmaecem em vez de se dobrar e as mudanças de etapa não deslizam. No modo de alto contraste, o QR code e o medidor de força mantêm suas formas.

Construído com

Os componentes gratuitos do HextaUI de que Security é feito. Cada um é instalado separadamente.

Código

12 arquivos, adicionados a components/blocks/security.