Ctrl K
ComunidadGuías…

src/features/account/security-page.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { Link } from '@tanstack/react-router'
import { Fingerprint, Laptop, Link2, LoaderCircle, ShieldCheck, Wallet } from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { Field, TextInput } from '@/components/product-form'
import { Button } from '@/components/ui/button'
import { Status } from '@/components/ui/status'
import {
  deletePasskey,
  renamePasskey,
  revokeOtherSessions,
  revokeSession,
  unlinkWalletAddress,
} from '@/lib/api/account'
import { tt } from '@/lib/i18n'
import * as Drawer from './account-drawer'
import { AccountSettingsNavigation } from './account-navigation'
import { AccountPageHeader } from './account-page'
import { useAccountCenterLayout } from './account-surface'
import {
  DestructiveConfirmationDialog,
  ItemList,
  SettingsAction,
  SubsectionTitle,
  useDestructiveConfirmation,
} from './primitives'
import { ProfilePasswordPanel } from './profile-page'
import {
  accountQueryKeys,
  useAccountMutation,
  useAccountPasskeys,
  useAccountProviderConnections,
  useAccountSecurity,
  useAccountSessions,
  useLinkedAccounts,
} from './queries'
import { PasskeyDialog, TotpDialogs } from './security-dialogs'
import type { defaultAccountCenterSettings } from './settings'
import type { ConfirmDestructiveHandler, MutationHandler, Passkey, SecurityState, UserSessionDevice } from './types'
import { enrollWallet, formatDate, formatSessionDevice, type TotpEnrollmentDisplay } from './utils'

export function AccountSecurityPage() {
  const { accountCenter, config, profile } = useAccountCenterLayout()
  const mutate = useAccountMutation()
  const [confirmation, setConfirmation] = useDestructiveConfirmation()
  return (
    <>
      <AccountPageHeader description={tt('Manage your profile and sign-in security.')} title={tt('Account settings')} />
      <AccountSettingsNavigation section="security" />
      <SecuritySections
        accountCenter={accountCenter}
        confirm={setConfirmation}
        mutate={mutate}
        profile={profile}
        walletProvider={config?.builtInProviders.web3Wallet}
      />
      <DestructiveConfirmationDialog confirmation={confirmation} onClose={() => setConfirmation(null)} />
    </>
  )
}

function SecuritySections({
  accountCenter,
  confirm,
  mutate,
  profile,
  walletProvider,
}: {
  accountCenter: typeof defaultAccountCenterSettings
  confirm: ConfirmDestructiveHandler
  mutate: MutationHandler
  profile: import('./types').UserProfile
  walletProvider?: import('./types').Web3WalletProvider
}) {
  const [tab, setTab] = useState<string | null>(null)
  const [dialog, setDialog] = useState<'mfa-enroll' | 'mfa-verify' | 'mfa-disable' | 'passkey' | null>(null)
  const [password, setPassword] = useState('')
  const [code, setCode] = useState('')
  const [passkeyName, setPasskeyName] = useState('')
  const [totpEnrollment, setTotpEnrollment] = useState<TotpEnrollmentDisplay | null>(null)
  const securityQuery = useAccountSecurity(true)
  const passkeysQuery = useAccountPasskeys(true)
  const sessionsQuery = useAccountSessions(accountCenter.sessionsViewEnabled)
  const linkedAccountsQuery = useLinkedAccounts(
    accountCenter.connectedAccountsEnabled && Boolean(walletProvider?.enabled),
  )
  const providerConnectionsQuery = useAccountProviderConnections(true)
  const security = securityQuery.data?.security ?? null
  const mfaRequired = security?.policy.mfa.mode === 'required'
  const mfaEnabled = Boolean(security?.mfa.enabled)
  return (
    <>
      <div className="accountSettingsSurface">
        <section className="accountSettingsGroup">
          <header>
            <h2>{tt('Sign-in methods')}</h2>
            <p>{tt('Choose how you sign in to Realmroot.')}</p>
          </header>
          <SecurityTabState
            error={securityQuery.error ?? passkeysQuery.error}
            loading={securityQuery.isLoading || passkeysQuery.isLoading}
          >
            <SettingsAction
              icon={<Fingerprint />}
              title={tt('Passkeys')}
              meta={tt('Use your fingerprint, face, or device PIN.')}
              value={tt('{{count}} passkeys', { count: passkeysQuery.data?.passkeys?.length ?? 0 })}
              action={
                <Button aria-label={tt('Manage passkeys')} variant="outline" onClick={() => setTab('passkeys')}>
                  {tt('Manage')}
                </Button>
              }
            />
          </SecurityTabState>
          {accountCenter.passwordChangeEnabled ? <ProfilePasswordPanel profile={profile} /> : null}
          <SecurityTabState error={providerConnectionsQuery.error} loading={providerConnectionsQuery.isLoading}>
            <SettingsAction
              icon={<Link2 />}
              title={tt('External accounts')}
              meta={
                (providerConnectionsQuery.data?.items ?? [])
                  .filter((item) => item.capabilities.signIn.active)
                  .map((item) => item.connector.displayName)
                  .join(', ') || tt('No external sign-in Providers')
              }
              action={
                <Button asChild variant="outline">
                  <Link to="/connections">{tt('Manage Connections')}</Link>
                </Button>
              }
            />
          </SecurityTabState>
          {walletProvider?.enabled ? (
            <SettingsAction
              icon={<Wallet />}
              title={tt('Web3 wallet')}
              meta={tt('Verify your wallet address with a signature.')}
              value={
                linkedAccountsQuery.error
                  ? tt('Unable to load.')
                  : tt('{{count}} linked', {
                      count: (linkedAccountsQuery.data?.items ?? []).filter((item) => item.providerId === 'siwe')
                        .length,
                    })
              }
              action={
                <Button aria-label={tt('Manage wallet')} variant="outline" onClick={() => setTab('wallet')}>
                  {tt('Manage')}
                </Button>
              }
            />
          ) : null}
        </section>
        <section className="accountSettingsGroup">
          <header>
            <h2>{tt('Two-step verification')}</h2>
            <p>{tt('Add another verification step to sign-in.')}</p>
          </header>
          <SecurityTabState error={securityQuery.error} loading={securityQuery.isLoading}>
            <SettingsAction
              icon={<ShieldCheck />}
              title={tt('Authenticator app')}
              meta={tt('Use one-time codes from your authenticator app.')}
              value={mfaEnabled ? tt('Enabled') : tt('Not set up')}
              action={
                <Button variant="outline" aria-label={tt('Manage authenticator')} onClick={() => setTab('mfa')}>
                  {tt(mfaEnabled ? 'Manage' : 'Set up')}
                </Button>
              }
            />
          </SecurityTabState>
        </section>
        {accountCenter.sessionsViewEnabled ? (
          <section className="accountSettingsGroup">
            <header>
              <h2>{tt('Login devices')}</h2>
              <p>{tt('Review your active sign-in sessions.')}</p>
            </header>
            <SecurityTabState error={sessionsQuery.error} loading={sessionsQuery.isLoading}>
              <SettingsAction
                icon={<Laptop />}
                title={tt('Sessions')}
                meta={formatSessionDevice(sessionsQuery.data?.items?.find((item) => item.current)?.userAgent ?? null)}
                value={tt('{{count}} sessions', { count: sessionsQuery.data?.items?.length ?? 0 })}
                action={
                  <Button aria-label={tt('Manage sessions')} variant="outline" onClick={() => setTab('sessions')}>
                    {tt('Manage')}
                  </Button>
                }
              />
            </SecurityTabState>
          </section>
        ) : null}
      </div>
      <Drawer.Dialog open={tab !== null && dialog === null} onOpenChange={(open) => !open && setTab(null)}>
        <Drawer.DialogContent>
          <Drawer.DialogHeader>
            <Drawer.DialogTitle>
              {tt(
                tab === 'passkeys'
                  ? 'Passkeys'
                  : tab === 'sessions'
                    ? 'Login devices'
                    : tab === 'wallet'
                      ? 'Web3 wallet'
                      : 'Authenticator app',
              )}
            </Drawer.DialogTitle>
            <Drawer.DialogDescription>{tt('Sign-in & security')}</Drawer.DialogDescription>
          </Drawer.DialogHeader>
          {tab === 'passkeys' ? (
            <SecurityTabState error={passkeysQuery.error} loading={passkeysQuery.isLoading}>
              <PasskeysPanel
                confirm={confirm}
                mutate={mutate}
                passkeys={passkeysQuery.data?.passkeys ?? []}
                security={security}
                setDialog={setDialog}
              />
            </SecurityTabState>
          ) : null}
          {tab === 'sessions' ? (
            <SecurityTabState error={sessionsQuery.error} loading={sessionsQuery.isLoading}>
              <SessionsPanel confirm={confirm} mutate={mutate} sessions={sessionsQuery.data?.items ?? []} />
            </SecurityTabState>
          ) : null}
          {tab === 'mfa' ? (
            <MfaPanel mfaEnabled={mfaEnabled} mfaRequired={mfaRequired} security={security} setDialog={setDialog} />
          ) : null}
          {tab === 'wallet' && walletProvider ? (
            <SecurityTabState error={linkedAccountsQuery.error} loading={linkedAccountsQuery.isLoading}>
              <WalletSignInPanel
                accounts={(linkedAccountsQuery.data?.items ?? []).filter((item) => item.providerId === 'siwe')}
                confirm={confirm}
                mutate={mutate}
                walletProvider={walletProvider}
              />
            </SecurityTabState>
          ) : null}
          <Drawer.DialogFooter>
            <Button variant="outline" onClick={() => setTab(null)}>
              {tt('Done')}
            </Button>
          </Drawer.DialogFooter>
        </Drawer.DialogContent>
      </Drawer.Dialog>
      <TotpDialogs
        code={code}
        dialog={dialog}
        mfaRequired={mfaRequired}
        mutate={mutate}
        password={password}
        profileEmail={profile.email}
        setCode={setCode}
        setDialog={setDialog}
        setPassword={setPassword}
        setTotpEnrollment={setTotpEnrollment}
        totpEnrollment={totpEnrollment}
      />
      <PasskeyDialog
        dialog={dialog}
        mutate={mutate}
        passkeyName={passkeyName}
        security={security}
        setDialog={setDialog}
        setPasskeyName={setPasskeyName}
      />
    </>
  )
}

function SecurityTabState({ children, error, loading }: { children: ReactNode; error: unknown; loading: boolean }) {
  if (loading)
    return (
      <Status>
        <LoaderCircle className="spin" size={18} />
        {tt('Loading security settings')}
      </Status>
    )
  if (error) return <Status tone="error">{error instanceof Error ? error.message : tt('Unable to load.')}</Status>
  return children
}

function WalletSignInPanel({
  accounts,
  confirm,
  mutate,
  walletProvider,
}: {
  accounts: import('./types').LinkedAccount[]
  confirm: ConfirmDestructiveHandler
  mutate: MutationHandler
  walletProvider: import('./types').Web3WalletProvider
}) {
  async function connectWallet() {
    await mutate('Wallet linked.', () => enrollWallet(walletProvider.chains ?? [1]), {
      invalidate: [accountQueryKeys.linkedAccounts],
    })
  }
  return (
    <section aria-label={tt('Web3 wallet')}>
      <SubsectionTitle
        description={tt('A cryptographic credential used directly for Realmroot sign-in.')}
        title={tt('Web3 wallet')}
      />
      <ItemList
        compactEmpty
        empty={tt('No wallet linked')}
        emptyDescription={tt('Link a wallet after signing in with an email-based account.')}
        emptyIcon={<Wallet size={18} />}
        items={accounts.map((account) => ({
          id: account.id,
          icon: <Wallet size={16} />,
          title: account.accountId,
          meta: tt('Linked {{date}}', { date: formatDate(account.createdAt) }),
          status: tt('Enabled'),
          action: (
            <Button
              onClick={() =>
                confirm({
                  title: tt('Unlink wallet'),
                  description: tt('This wallet will no longer sign in to your account.'),
                  actionLabel: tt('Unlink wallet'),
                  onConfirm: () =>
                    mutate('Wallet removed.', () => unlinkWalletAddress(account.accountId), {
                      invalidate: [accountQueryKeys.linkedAccounts],
                    }),
                })
              }
              variant="ghost"
            >
              {tt('Unlink')}
            </Button>
          ),
        }))}
      />
      {!accounts.length ? (
        <div className="accountPanelActions">
          <Button onClick={() => void connectWallet()} variant="outline">
            {tt('Link wallet')}
          </Button>
        </div>
      ) : null}
    </section>
  )
}

function MfaPanel({
  mfaEnabled,
  mfaRequired,
  security,
  setDialog,
}: {
  mfaEnabled: boolean
  mfaRequired: boolean
  security: SecurityState | null
  setDialog: (dialog: 'mfa-enroll' | 'mfa-verify' | 'mfa-disable') => void
}) {
  return (
    <section className="settingsPanel">
      <SettingsAction
        action={
          <div className="settingsActionButtons">
            {mfaEnabled ? (
              <>
                <Button
                  aria-label={tt('Verify code')}
                  onClick={() => setDialog('mfa-verify')}
                  size="sm"
                  type="button"
                  variant="outline"
                >
                  {tt('Verify')}
                </Button>
                <Button
                  aria-label={tt('Disable MFA')}
                  disabled={mfaRequired}
                  onClick={() => setDialog('mfa-disable')}
                  size="sm"
                  type="button"
                  variant="destructive"
                >
                  {tt('Disable')}
                </Button>
              </>
            ) : (
              <Button
                aria-label={tt('Set up authenticator app')}
                onClick={() => setDialog('mfa-enroll')}
                size="sm"
                type="button"
                variant="outline"
              >
                {tt('Set up')}
              </Button>
            )}
          </div>
        }
        icon={<ShieldCheck size={18} />}
        meta={
          security?.mfa.enabled
            ? tt('Authenticator app is enabled.')
            : tt('Protect your account with an authenticator app.')
        }
        title={tt('Multi-factor authentication')}
        value={mfaEnabled ? tt('Enabled') : tt('Not set up')}
      />
    </section>
  )
}

function PasskeysPanel({
  confirm,
  mutate,
  passkeys,
  security,
  setDialog,
}: {
  confirm: ConfirmDestructiveHandler
  mutate: MutationHandler
  passkeys: Passkey[]
  security: SecurityState | null
  setDialog: (dialog: 'passkey') => void
}) {
  const [editing, setEditing] = useState<string | null>(null)
  const [name, setName] = useState('')
  const [saving, setSaving] = useState(false)
  const [error, setError] = useState<string | null>(null)
  return (
    <section className="accountPasskeyManager">
      <div className="accountDrawerIntro">
        <h2>{tt('Your passkeys')}</h2>
        <p>{tt('{{count}} registered keys, managed individually.', { count: passkeys.length })}</p>
      </div>
      <div className="accountPanelActions">
        <Button disabled={!security?.policy.passkeys.enabled} onClick={() => setDialog('passkey')}>
          <Fingerprint />
          {tt('Add passkey')}
        </Button>
      </div>
      {passkeys.map((passkey) => (
        <article className="accountCredentialCard" key={passkey.id}>
          <div className="accountCredentialHeading">
            <Fingerprint />
            <div>
              <h3>{passkey.name ?? tt('Unnamed passkey')}</h3>
              <p>
                {tt(passkey.deviceType === 'multiDevice' ? 'Syncable' : 'Device-bound')} ·{' '}
                {tt(passkey.backedUp ? 'Backed up' : 'Not backed up')}
              </p>
            </div>
          </div>
          {editing === passkey.id ? (
            <form
              onSubmit={async (event) => {
                event.preventDefault()
                if (!name.trim()) return
                setSaving(true)
                setError(null)
                const result = await mutate('Passkey renamed.', () => renamePasskey(passkey.id, name.trim()), {
                  invalidate: [accountQueryKeys.passkeys],
                  onError: setError,
                })
                setSaving(false)
                if (result) setEditing(null)
              }}
            >
              <Field label={tt('Passkey name')}>
                <TextInput value={name} required maxLength={100} onChange={(event) => setName(event.target.value)} />
              </Field>
              {error ? (
                <p role="alert" className="text-destructive">
                  {error}
                </p>
              ) : null}
              <div className="accountPanelActions">
                <Button type="button" variant="outline" disabled={saving} onClick={() => setEditing(null)}>
                  {tt('Cancel')}
                </Button>
                <Button type="submit" disabled={saving || !name.trim()}>
                  {tt('Save')}
                </Button>
              </div>
            </form>
          ) : (
            <div className="accountCredentialFooter">
              <span>
                {passkey.createdAt
                  ? tt('Created {{date}}', { date: formatDate(passkey.createdAt) })
                  : tt('Creation date unavailable')}
              </span>
              <div>
                <Button
                  variant="outline"
                  size="sm"
                  disabled={!security?.policy.passkeys.enabled}
                  onClick={() => {
                    setEditing(passkey.id)
                    setName(passkey.name ?? '')
                    setError(null)
                  }}
                >
                  {tt('Rename')}
                </Button>
                <Button
                  variant="outline"
                  size="sm"
                  disabled={!security?.policy.passkeys.enabled}
                  onClick={() =>
                    confirm({
                      title: tt('Remove passkey'),
                      description:
                        passkeys.length === 1
                          ? tt('This is your last passkey. Use another sign-in method after removing it.')
                          : tt('This passkey will no longer sign in to your account.'),
                      actionLabel: tt('Remove passkey'),
                      onConfirm: () =>
                        mutate('Passkey removed.', () => deletePasskey(passkey.id), {
                          invalidate: [accountQueryKeys.passkeys, accountQueryKeys.security],
                        }),
                    })
                  }
                >
                  {tt('Remove')}
                </Button>
              </div>
            </div>
          )}
        </article>
      ))}
      {!passkeys.length ? (
        <ItemList
          empty={tt('No passkeys have been added yet.')}
          emptyDescription={tt('Add a passkey to sign in without a password.')}
          items={[]}
        />
      ) : null}
      <p className="accountDrawerHelp">
        {tt('A synced passkey may be used on several devices. This list shows registered keys, not login devices.')}
      </p>
    </section>
  )
}

function SessionsPanel({
  confirm,
  mutate,
  sessions,
}: {
  confirm: ConfirmDestructiveHandler
  mutate: MutationHandler
  sessions: UserSessionDevice[]
}) {
  return (
    <section className="accountSessionManager" aria-label={tt('Session management')}>
      <div className="accountDrawerIntro">
        <h2>{tt('Active sign-in sessions')}</h2>
        <p>{tt('Review your devices. Revoked sessions must sign in again.')}</p>
      </div>
      {sessions.map((session) => (
        <article className="accountCredentialCard" key={session.id}>
          <div className="accountCredentialHeading">
            <Laptop />
            <div>
              <h3>{formatSessionDevice(session.userAgent)}</h3>
              <p>{session.ipAddress?.trim() || tt('Unknown IP')}</p>
            </div>
            {session.current ? <span className="accountCurrentSession">{tt('Current')}</span> : null}
          </div>
          <dl className="accountSessionDates">
            {session.createdAt ? (
              <>
                <dt>{tt('Signed in')}</dt>
                <dd>{formatDate(session.createdAt)}</dd>
              </>
            ) : null}
            <dt>{tt('Expires')}</dt>
            <dd>{formatDate(session.expiresAt)}</dd>
          </dl>
          {!session.current ? (
            <div className="accountPanelActions">
              <Button
                type="button"
                variant="outline"
                onClick={() =>
                  confirm({
                    title: tt('Revoke session'),
                    description: tt('This device session will be signed out.'),
                    actionLabel: tt('Revoke session'),
                    onConfirm: () => revokeUserSession(session, mutate),
                  })
                }
              >
                {tt('Revoke')}
              </Button>
            </div>
          ) : null}
        </article>
      ))}
      {!sessions.length ? (
        <ItemList
          empty={tt('No other active sessions.')}
          emptyDescription={tt('This browser is your only active session.')}
          items={[]}
        />
      ) : null}
      <div className="accountPanelActions">
        <Button
          aria-label={tt('Sign out others')}
          variant="destructive"
          onClick={() =>
            confirm({
              title: tt('Revoke other sessions'),
              description: tt('Every other active session for this account will be signed out.'),
              actionLabel: tt('Revoke sessions'),
              onConfirm: () =>
                mutate('Other sessions revoked.', revokeOtherSessions, { invalidate: [accountQueryKeys.sessions] }),
            })
          }
        >
          {tt('Sign out others')}
        </Button>
      </div>
    </section>
  )
}

async function revokeUserSession(session: UserSessionDevice, mutate: MutationHandler) {
  await mutate('Session revoked.', () => revokeSession(session.id), {
    invalidate: [accountQueryKeys.sessions],
  })
}