HextaUI

Security

Sessions and security for an AI product. Active devices with sign-out that animates rows away, a password change with a live strength meter, two-factor setup with a real QR code, a 6-digit check and downloadable recovery codes, passkeys through WebAuthn, and account deletion behind a typed confirmation.

Every AI product ends up with the same security page: where you’re signed in, how you sign in, and how to leave. Security is that page, built on the Settings block’s groups and rows, with every flow wired to async callbacks you own.

Active sessions lists each device with its browser, system, location and when it was last active, in plain relative time. This device is marked and can’t be signed out from here. Signing out of one device shows progress in its button, then the row folds away and focus moves to the next row. Signing out everywhere else asks first and names the devices it will sign out.

Change password checks as you type: a four-step strength meter and a checklist of what’s still missing, without blocking anything you type. If your server says the current password is wrong, the message shows under that field and focus goes back to it, with everything you typed kept. You can sign out other sessions in the same step.

Two-factor setup draws a real, scannable QR code for the otpauth link your server returns, shows the setup key with a copy button for when scanning isn’t possible, and on a phone offers to open the link in an authenticator app. The 6-digit code checks itself as soon as it’s complete, shakes and clears on a wrong code, then shows recovery codes to copy or download as a text file. Turning it off asks first.

Passkeys calls your WebAuthn flow and handles the browser’s answers: closing the prompt adds nothing and shows no error, and a device that already has a passkey says so. Deleting the account needs the exact phrase, lists what goes with it, and keeps the dialog open with your message if the server says no.

  1. Add the Pro registry to components.json

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

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  3. Add the block

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

Wire it to your API

Pass what the account has now and an async callback for each action. Resolve when the server is done and refresh the data, and each part shows its own progress. Throw to show your error message in place, and the person’s input stays.

"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 the parts on their own

SecuritySessions, SecurityPasswordRow, SecurityTwoFactorRow, SecurityPasskeys and SecurityDeleteAccount each work alone, so you can put them in any section. Return field errors from onChangePassword to show them under the field.

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

Anatomy

The parts you compose, from the outside in.

PartDescription
SecuritySettingsEverything below in one call. Each part appears when you pass the data and callbacks it needs.
SecuritySessionsA group listing signed-in devices, with sign out per device and for all other sessions.
SecurityPasswordRowA row that opens the change password dialog.
SecurityTwoFactorRowA row with the two-factor status, the setup dialog and the turn-off confirmation.
SecurityPasskeysA group listing passkeys, with add and remove.
SecurityDeleteAccountA group with the delete account row and its typed confirmation.
measurePasswordThe strength check the password dialog uses, for your own forms.
PropTypeDefault
emailThe account’s email. Used in the delete phrase, the password manager hint and the recovery codes file.
string–
sessions{ id, browser, os, device?, location?, lastActive, current? }. device is "desktop", "laptop", "phone" or "tablet".
SecuritySession[]–
onSignOutSessionSign out one session. Remove it from sessions when it resolves and the row folds away.
(id: string) => Promise<void>–
onSignOutOtherSessionsSign out every session except the current one. Runs after the person confirms.
() => Promise<void>–
passwordChangedAtShown as Last changed under Password.
Date | string | number | null–
onChangePasswordReturn { currentPassword } or { newPassword } to show a message under that field, or throw to show it above the buttons.
({ currentPassword, newPassword, signOutOthers }) => Promise<void | errors>–
twoFactorEnabledWhether two-factor is on.
booleanfalse
onStartTwoFactorCreate a pending secret when the setup dialog opens. uri is the otpauth link drawn as the QR code; secret defaults to the one in uri.
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifyTwoFactorCheck the 6-digit code and turn two-factor on. Return recovery codes to show them, or throw if the code is wrong.
(code: string) => Promise<void | string[]>–
onDisableTwoFactorTurn two-factor off. Runs after the person confirms.
() => Promise<void>–
passkeys{ id, name, createdAt, lastUsed? }.
SecurityPasskey[]–
onAddPasskeyRun your WebAuthn registration. A NotAllowedError or AbortError counts as cancelled and shows nothing.
() => Promise<void>–
onRemovePasskeyRemove a passkey. Runs after the person confirms.
(id: string) => Promise<void>–
passkeysSupportedOverride the check for WebAuthn support.
booleandetected
onDeleteAccountDelete the account. Runs after the person types the phrase. Throw to keep the dialog open with your message.
() => Promise<void>–
deleteConsequencesWhat gets deleted, listed in the confirmation.
ReactNode[]–

SecuritySessions

Also accepts every SettingsGroup prop.

PropTypeDefault
sessionsThe signed-in devices.
SecuritySession[]–
onSignOutSign out one session.
(id: string) => Promise<void>–
onSignOutOthersSign out every other session.
() => Promise<void>–
titleThe group heading.
ReactNode"Active sessions"
PropTypeDefault
emailFilled into a hidden username field so password managers update the right login.
string–
changedAtWhen the password last changed.
Date | string | number | null–
onChangePasswordSame as on SecuritySettings.
(change) => Promise<void | errors>–
PropTypeDefault
emailNamed in the confirmation and the recovery codes file.
string–
enabledWhether two-factor is on.
boolean–
onStartSame as onStartTwoFactor.
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifySame as onVerifyTwoFactor.
(code: string) => Promise<void | string[]>–
onDisableSame as onDisableTwoFactor.
() => Promise<void>–

SecurityPasskeys

Also accepts every SettingsGroup prop.

PropTypeDefault
passkeysThe saved passkeys.
SecurityPasskey[]–
onAddRun your WebAuthn registration.
() => Promise<void>–
onRemoveRemove a passkey.
(id: string) => Promise<void>–
supportedWhether this browser can create passkeys.
booleandetected

SecurityDeleteAccount

Also accepts every SettingsGroup prop.

PropTypeDefault
emailThe confirmation phrase is delete followed by this.
string–
onDeleteDelete the account.
() => Promise<void>–
consequencesWhat gets deleted with it.
ReactNode[]–
titleThe group heading.
ReactNode"Danger zone"
KeyAction
TabMoves through the rows and their buttons, then through each dialog.
EnterSubmits the password form from any field, and deletes the account from the phrase field once it matches.
EscapeCloses a dialog, except while something is saving.
0–9Fills the verification code. It checks itself once all six digits are in, and pasting a code works too.
  • Destructive actions never get focus first. Confirmations name what they affect, like the devices being signed out or the email being deleted, and the delete phrase field gets focus instead of the button.
  • When a row folds away, focus moves to the next row’s button, or the previous one, so it never drops to the page. Closing a dialog returns focus to the button that opened it, or to its replacement when that button is gone.
  • Sign-outs, added and removed passkeys and the password strength are announced politely. Errors from your callbacks are announced as alerts and stay next to the field or button they belong to.
  • Each password requirement says whether it’s met, the QR code has a text label, and the setup key and recovery codes are readable text you can select.
  • With reduced motion, rows fade instead of folding and step changes don’t slide. In high contrast mode the QR code and strength meter keep their shapes.

Code

12 files, added to components/blocks/security.