セキュリティ

AIプロダクト向けのセッションとセキュリティです。行がアニメーションで消えるサインアウトつきのアクティブなデバイス、リアルタイムの強度メーターつきのパスワード変更、本物のQRコードを使う二要素認証の設定、6桁の確認とダウンロードできるリカバリーコード、WebAuthnによるパスキー、入力による確認が必要なアカウント削除を備えています。

どのAIプロダクトも、結局は同じセキュリティページに行き着きます。どこでサインインしているか、どうやってサインインするか、どうやって離れるか、です。Securityはそのページで、Settingsブロックのグループと行の上に作られ、すべてのフローが自分で用意する非同期コールバックに接続されています。

Active sessionsは、各デバイスをブラウザー、システム、場所、最終アクティブ時刻とともに、わかりやすい相対時間で一覧します。このデバイスにはマークが付き、ここからはサインアウトできません。1つのデバイスをサインアウトすると、そのボタンに進捗が表示され、行が折りたたまれてフォーカスが次の行に移ります。他のすべてをサインアウトする場合は、先に確認を求め、サインアウトされるデバイスを名前で示します。

パスワードの変更は入力しながら確認します。4段階の強度メーターと、まだ足りない項目のチェックリストがあり、入力を妨げることはありません。現在のパスワードが間違っているとサーバーが返した場合、そのフィールドの下にメッセージが表示されてフォーカスが戻り、入力した内容は保持されます。同じステップで他のセッションをサインアウトすることもできます。

二要素認証の設定では、サーバーが返すotpauthリンクから、実際にスキャンできるQRコードを描画し、スキャンできない場合に備えてコピーボタンつきの設定キーを表示し、スマートフォンではリンクを認証アプリで開くよう提案します。6桁のコードは入力が完了した時点で自動的に確認され、間違っていると揺れてクリアされ、その後、コピーまたはテキストファイルとしてダウンロードできるリカバリーコードが表示されます。オフにする際は、先に確認を求めます。

PasskeysはWebAuthnのフローを呼び出し、ブラウザーの応答を処理します。プロンプトを閉じても何も追加されずエラーも表示されず、すでにパスキーがあるデバイスはその旨を伝えます。アカウントの削除には正確なフレーズが必要で、一緒に失われるものを一覧し、サーバーが拒否した場合はメッセージとともにダイアログが開いたままになります。

  1. Proレジストリをcomponents.jsonに追加する

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. トークンを追加する

    アカウントページでトークンを作成し、.env.local に HEXTAUI_PRO_TOKEN として設定してください。

  3. ブロックを追加する

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

APIに接続する

アカウントの現在の状態と、各アクション用の非同期コールバックを渡します。サーバーの処理が終わったらresolveしてデータを更新すると、各パーツが独自の進捗を表示します。エラーを投げると、そのエラーメッセージがその場に表示され、ユーザーの入力は保持されます。

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

パーツを単独で使う

SecuritySessions、SecurityPasswordRow、SecurityTwoFactorRow、SecurityPasskeys、SecurityDeleteAccountはそれぞれ単独で動作するため、任意のセクションに置けます。フィールドの下にエラーを表示するには、onChangePasswordからフィールドエラーを返します。

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

構造

外側から内側へ組み合わせるパーツ。

パーツ説明
SecuritySettings以下のすべてを1回の呼び出しで。各パーツは、必要なデータとコールバックを渡したときに表示されます。
SecuritySessionsサインイン中のデバイスを一覧し、デバイスごと、および他のすべてのセッションに対するサインアウトを備えたグループ。
SecurityPasswordRowパスワード変更ダイアログを開く行。
SecurityTwoFactorRow二要素認証のステータス、設定ダイアログ、オフにする確認を備えた行。
SecurityPasskeysパスキーを一覧し、追加と削除ができるグループ。
SecurityDeleteAccountアカウント削除の行と、入力による確認を備えたグループ。
measurePasswordパスワードダイアログが使う強度チェック。独自のフォーム向けです。
プロパティ型デフォルト
emailアカウントのメールアドレス。削除フレーズ、パスワードマネージャーのヒント、リカバリーコードのファイルに使われます。
string–
sessions{ id, browser, os, device?, location?, lastActive, current? }。deviceは "desktop"、"laptop"、"phone"、"tablet" のいずれかです。
SecuritySession[]–
onSignOutSession1つのセッションをサインアウトします。resolveされたらsessionsから削除すると、行が折りたたまれます。
(id: string) => Promise<void>–
onSignOutOtherSessions現在のセッション以外のすべてのセッションをサインアウトします。ユーザーが確認した後に実行されます。
() => Promise<void>–
passwordChangedAtPasswordの下にLast changedとして表示されます。
Date | string | number | null–
onChangePasswordそのフィールドの下にメッセージを表示するには { currentPassword } または { newPassword } を返し、ボタンの上に表示するにはエラーを投げます。
({ currentPassword, newPassword, signOutOthers }) => Promise<void | errors>–
twoFactorEnabled二要素認証がオンかどうか。
booleanfalse
onStartTwoFactor設定ダイアログが開いたときに、保留中のシークレットを作成します。uriはQRコードとして描画されるotpauthリンクで、secretのデフォルトはuriに含まれるものです。
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifyTwoFactor6桁のコードを確認し、二要素認証をオンにします。リカバリーコードを表示するには返し、コードが間違っている場合はエラーを投げます。
(code: string) => Promise<void | string[]>–
onDisableTwoFactor二要素認証をオフにします。ユーザーが確認した後に実行されます。
() => Promise<void>–
passkeys{ id, name, createdAt, lastUsed? }。
SecurityPasskey[]–
onAddPasskeyWebAuthnの登録を実行します。NotAllowedErrorまたはAbortErrorはキャンセルとして扱われ、何も表示されません。
() => Promise<void>–
onRemovePasskeyパスキーを削除します。ユーザーが確認した後に実行されます。
(id: string) => Promise<void>–
passkeysSupportedWebAuthnのサポート確認を上書きします。
booleandetected
onDeleteAccountアカウントを削除します。ユーザーがフレーズを入力した後に実行されます。エラーを投げると、メッセージとともにダイアログが開いたままになります。
() => Promise<void>–
deleteConsequences削除されるものを、確認に一覧表示します。
ReactNode[]–

SecuritySessions

すべてのSettingsGroupプロップも受け付けます。

プロパティ型デフォルト
sessionsサインイン中のデバイス。
SecuritySession[]–
onSignOut1つのセッションをサインアウトします。
(id: string) => Promise<void>–
onSignOutOthers他のすべてのセッションをサインアウトします。
() => Promise<void>–
titleグループの見出し。
ReactNode"Active sessions"
プロパティ型デフォルト
emailパスワードマネージャーが正しいログインを更新できるよう、非表示のユーザー名フィールドに入力されます。
string–
changedAtパスワードが最後に変更された日時。
Date | string | number | null–
onChangePasswordSecuritySettingsと同じです。
(change) => Promise<void | errors>–
プロパティ型デフォルト
email確認とリカバリーコードのファイルに表示される名前。
string–
enabled二要素認証がオンかどうか。
boolean–
onStartonStartTwoFactorと同じです。
() => Promise<{ uri, secret?, recoveryCodes? }>–
onVerifyonVerifyTwoFactorと同じです。
(code: string) => Promise<void | string[]>–
onDisableonDisableTwoFactorと同じです。
() => Promise<void>–

SecurityPasskeys

すべてのSettingsGroupプロップも受け付けます。

プロパティ型デフォルト
passkeys保存済みのパスキー。
SecurityPasskey[]–
onAddWebAuthnの登録を実行します。
() => Promise<void>–
onRemoveパスキーを削除します。
(id: string) => Promise<void>–
supportedこのブラウザーがパスキーを作成できるかどうか。
booleandetected

SecurityDeleteAccount

すべてのSettingsGroupプロップも受け付けます。

プロパティ型デフォルト
email確認フレーズは、deleteに続けてこの値を付けたものです。
string–
onDeleteアカウントを削除します。
() => Promise<void>–
consequences一緒に削除されるもの。
ReactNode[]–
titleグループの見出し。
ReactNode"Danger zone"
キーアクション
Tab行とそのボタンを移動し、その後、各ダイアログ内を移動します。
Enterどのフィールドからでもパスワードフォームを送信し、フレーズのフィールドが一致するとそこからアカウントを削除します。
Escapeダイアログを閉じます。保存中は閉じません。
0–9確認コードを入力します。6桁がすべて入ると自動で確認され、コードの貼り付けにも対応します。
  • 破壊的な操作が最初にフォーカスされることはありません。確認ではサインアウトされるデバイスや削除されるメールアドレスなど、影響を受けるものが示され、ボタンではなく削除フレーズのフィールドにフォーカスが移ります。
  • 行が折りたたまれて消えると、フォーカスは次の行のボタン、または前の行のボタンに移るため、ページに落ちることはありません。ダイアログを閉じると、フォーカスは開いたボタン、そのボタンがなくなっている場合はその代わりの要素に戻ります。
  • サインアウト、パスキーの追加と削除、パスワードの強度は、politeに読み上げられます。コールバックからのエラーはalertとして読み上げられ、関連するフィールドやボタンの近くに表示されたままになります。
  • パスワードの各要件は満たされているかを伝え、QRコードにはテキストのラベルがあり、設定キーとリカバリーコードは選択できる読み取り可能なテキストです。
  • モーション軽減時は、行は折りたたまれずにフェードし、ステップの切り替えもスライドしません。ハイコントラストモードでは、QRコードと強度メーターの形が保たれます。

使用技術

Security を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。

コード

12 個のファイルを components/blocks/security に追加しました。