Sicherheit

Sitzungen und Sicherheit für ein KI-Produkt. Aktive Geräte mit Abmelden, bei dem Zeilen animiert verschwinden, eine Passwortänderung mit Live-Stärkeanzeige, Zwei-Faktor-Einrichtung mit echtem QR-Code, einer 6-stelligen Prüfung und herunterladbaren Wiederherstellungscodes, Passkeys über WebAuthn und Kontolöschung hinter einer getippten Bestätigung.

Jedes KI-Produkt landet bei derselben Sicherheitsseite: wo du angemeldet bist, wie du dich anmeldest und wie du dich abmeldest. Security ist diese Seite, gebaut auf den Gruppen und Zeilen des Settings-Blocks, wobei jeder Ablauf an asynchrone Callbacks angebunden ist, die dir gehören.

Active sessions listet jedes Gerät mit Browser, System, Standort und letzter Aktivität in einfacher relativer Zeit auf. Dieses Gerät ist markiert und kann von hier nicht abgemeldet werden. Das Abmelden eines Geräts zeigt den Fortschritt in seinem Button, dann klappt die Zeile weg, und der Fokus springt zur nächsten Zeile. Das Abmelden überall sonst fragt zuerst nach und nennt die Geräte, die abgemeldet werden.

Change password prüft beim Tippen: eine vierstufige Stärkeanzeige und eine Checkliste dessen, was noch fehlt, ohne etwas von dem zu blockieren, was du tippst. Sagt dein Server, dass das aktuelle Passwort falsch ist, erscheint die Meldung unter diesem Feld und der Fokus geht dorthin zurück, wobei alles Getippte erhalten bleibt. Im selben Schritt kannst du andere Sitzungen abmelden.

Die Zwei-Faktor-Einrichtung zeichnet einen echten, scanbaren QR-Code für den otpauth-Link, den dein Server zurückgibt, zeigt den Einrichtungsschlüssel mit einem Kopieren-Button für den Fall, dass Scannen nicht möglich ist, und bietet auf dem Smartphone an, den Link in einer Authenticator-App zu öffnen. Der 6-stellige Code prüft sich selbst, sobald er vollständig ist, wackelt und leert sich bei einem falschen Code und zeigt dann Wiederherstellungscodes zum Kopieren oder Herunterladen als Textdatei. Das Ausschalten fragt zuerst nach.

Passkeys ruft deinen WebAuthn-Ablauf auf und behandelt die Antworten des Browsers: Das Schließen der Abfrage fügt nichts hinzu und zeigt keinen Fehler, und ein Gerät, das bereits einen Passkey hat, sagt das. Das Löschen des Kontos erfordert die exakte Phrase, listet auf, was damit verschwindet, und hält den Dialog mit deiner Meldung offen, wenn der Server ablehnt.

  1. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  3. Den Block hinzufügen

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

An deine API anbinden

Übergib, was das Konto jetzt hat, und einen asynchronen Callback für jede Aktion. Löse auf, wenn der Server fertig ist, und aktualisiere die Daten, und jeder Teil zeigt seinen eigenen Fortschritt. Wirf einen Fehler, um deine Fehlermeldung an Ort und Stelle zu zeigen, und die Eingabe der Person bleibt erhalten.

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

Die Teile einzeln verwenden

SecuritySessions, SecurityPasswordRow, SecurityTwoFactorRow, SecurityPasskeys und SecurityDeleteAccount funktionieren jeweils allein, sodass du sie in jeden Bereich setzen kannst. Gib Feldfehler aus onChangePassword zurück, um sie unter dem Feld zu zeigen.

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

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
SecuritySettingsAlles Folgende in einem Aufruf. Jeder Teil erscheint, wenn du die Daten und Callbacks übergibst, die er braucht.
SecuritySessionsEine Gruppe, die angemeldete Geräte auflistet, mit Abmelden pro Gerät und für alle anderen Sitzungen.
SecurityPasswordRowEine Zeile, die den Dialog zum Ändern des Passworts öffnet.
SecurityTwoFactorRowEine Zeile mit dem Zwei-Faktor-Status, dem Einrichtungsdialog und der Bestätigung zum Ausschalten.
SecurityPasskeysEine Gruppe, die Passkeys auflistet, mit Hinzufügen und Entfernen.
SecurityDeleteAccountEine Gruppe mit der Zeile zum Löschen des Kontos und ihrer getippten Bestätigung.
measurePasswordDie Stärkeprüfung, die der Passwortdialog verwendet, für deine eigenen Formulare.
PropTypStandard
emailDie E-Mail des Kontos. Wird in der Löschphrase, im Passwortmanager-Hinweis und in der Datei mit den Wiederherstellungscodes verwendet.
string–
sessions{ id, browser, os, device?, location?, lastActive, current? }. device ist "desktop", "laptop", "phone" oder "tablet".
SecuritySession[]–
onSignOutSessionMeldet eine Sitzung ab. Entferne sie aus sessions, wenn es aufgelöst ist, und die Zeile klappt weg.
(id: string) => Promise<void>–
onSignOutOtherSessionsMeldet jede Sitzung außer der aktuellen ab. Läuft, nachdem die Person bestätigt hat.
() => Promise<void>–
passwordChangedAtWird als Last changed unter Password angezeigt.
Date | string | number | null–
onChangePasswordGib { currentPassword } oder { newPassword } zurück, um eine Meldung unter diesem Feld zu zeigen, oder wirf einen Fehler, um sie über den Buttons zu zeigen.
({ currentPassword, newPassword, signOutOthers }) => Promise<void | errors>–
twoFactorEnabledOb Zwei-Faktor aktiv ist.
booleanfalse
onStartTwoFactorErzeugt ein ausstehendes Secret, wenn sich der Einrichtungsdialog öffnet. uri ist der otpauth-Link, der als QR-Code gezeichnet wird; secret ist standardmäßig das aus uri.
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifyTwoFactorPrüft den 6-stelligen Code und schaltet Zwei-Faktor ein. Gib Wiederherstellungscodes zurück, um sie zu zeigen, oder wirf einen Fehler, wenn der Code falsch ist.
(code: string) => Promise<void | string[]>–
onDisableTwoFactorSchaltet Zwei-Faktor aus. Läuft, nachdem die Person bestätigt hat.
() => Promise<void>–
passkeys{ id, name, createdAt, lastUsed? }.
SecurityPasskey[]–
onAddPasskeyFührt deine WebAuthn-Registrierung aus. Ein NotAllowedError oder AbortError gilt als abgebrochen und zeigt nichts.
() => Promise<void>–
onRemovePasskeyEntfernt einen Passkey. Läuft, nachdem die Person bestätigt hat.
(id: string) => Promise<void>–
passkeysSupportedÜberschreibt die Prüfung auf WebAuthn-Unterstützung.
booleandetected
onDeleteAccountLöscht das Konto. Läuft, nachdem die Person die Phrase getippt hat. Wirf einen Fehler, um den Dialog mit deiner Meldung offen zu halten.
() => Promise<void>–
deleteConsequencesWas gelöscht wird, in der Bestätigung aufgelistet.
ReactNode[]–

SecuritySessions

Akzeptiert auch alle SettingsGroup-Props.

PropTypStandard
sessionsDie angemeldeten Geräte.
SecuritySession[]–
onSignOutMeldet eine Sitzung ab.
(id: string) => Promise<void>–
onSignOutOthersMeldet jede andere Sitzung ab.
() => Promise<void>–
titleDie Gruppenüberschrift.
ReactNode"Active sessions"
PropTypStandard
emailWird in ein verborgenes Benutzernamenfeld eingetragen, damit Passwortmanager den richtigen Login aktualisieren.
string–
changedAtWann das Passwort zuletzt geändert wurde.
Date | string | number | null–
onChangePasswordWie bei SecuritySettings.
(change) => Promise<void | errors>–
PropTypStandard
emailIn der Bestätigung und der Datei mit den Wiederherstellungscodes genannt.
string–
enabledOb Zwei-Faktor aktiv ist.
boolean–
onStartWie bei onStartTwoFactor.
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifyWie bei onVerifyTwoFactor.
(code: string) => Promise<void | string[]>–
onDisableWie bei onDisableTwoFactor.
() => Promise<void>–

SecurityPasskeys

Akzeptiert auch alle SettingsGroup-Props.

PropTypStandard
passkeysDie gespeicherten Passkeys.
SecurityPasskey[]–
onAddFührt deine WebAuthn-Registrierung aus.
() => Promise<void>–
onRemoveEntfernt einen Passkey.
(id: string) => Promise<void>–
supportedOb dieser Browser Passkeys erstellen kann.
booleandetected

SecurityDeleteAccount

Akzeptiert auch alle SettingsGroup-Props.

PropTypStandard
emailDie Bestätigungsphrase ist delete gefolgt von diesem Wert.
string–
onDeleteLöscht das Konto.
() => Promise<void>–
consequencesWas damit gelöscht wird.
ReactNode[]–
titleDie Gruppenüberschrift.
ReactNode"Danger zone"
TasteAktion
TabWechselt durch die Zeilen und ihre Buttons, dann durch jeden Dialog.
EnterSendet das Passwortformular aus jedem Feld ab und löscht das Konto aus dem Phrasenfeld, sobald es übereinstimmt.
EscapeSchließt einen Dialog, außer während etwas gespeichert wird.
0–9Füllt den Bestätigungscode. Er prüft sich selbst, sobald alle sechs Ziffern drin sind, und auch das Einfügen eines Codes funktioniert.
  • Destruktive Aktionen erhalten nie zuerst den Fokus. Bestätigungen nennen, was sie betreffen, etwa die abzumeldenden Geräte oder die zu löschende E-Mail, und das Feld für die Löschphrase erhält den Fokus statt des Buttons.
  • Wenn eine Zeile wegklappt, springt der Fokus zum Button der nächsten oder vorherigen Zeile, sodass er nie auf die Seite fällt. Das Schließen eines Dialogs gibt den Fokus an den Button zurück, der ihn geöffnet hat, oder an dessen Ersatz, wenn dieser Button weg ist.
  • Abmeldungen, hinzugefügte und entfernte Passkeys und die Passwortstärke werden höflich angesagt. Fehler aus deinen Callbacks werden als Alerts angesagt und bleiben neben dem Feld oder Button, zu dem sie gehören.
  • Jede Passwortanforderung sagt, ob sie erfüllt ist, der QR-Code hat ein Textlabel, und Einrichtungsschlüssel und Wiederherstellungscodes sind lesbarer, markierbarer Text.
  • Bei reduzierter Bewegung blenden Zeilen aus, statt einzuklappen, und Schrittwechsel gleiten nicht. Im Hochkontrastmodus behalten QR-Code und Stärkeanzeige ihre Formen.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Security besteht. Jede lässt sich einzeln installieren.

Code

12 Dateien, hinzugefügt zu components/blocks/security.