SecurityStatusCard.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { useQuery } from '@tanstack/react-query';
  2. import { useTranslation } from 'react-i18next';
  3. import { Shield, ShieldCheck, ShieldOff, AlertTriangle, XCircle, Loader2 } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { EncryptionStatus } from '../api/client';
  6. import { Card, CardContent, CardHeader } from './Card';
  7. import { registerSettingsSearch } from '../lib/settingsSearch';
  8. // Cross-tab search registration so this card surfaces in
  9. // Settings → Search results under the users → security sub-tab.
  10. registerSettingsSearch({
  11. labelKey: 'settings.encryption.title',
  12. labelFallback: 'MFA Encryption Status',
  13. tab: 'users',
  14. subTab: 'security',
  15. keywords: 'mfa encryption status security backup totp oidc fernet',
  16. anchor: 'card-mfa-encryption',
  17. });
  18. /**
  19. * Read-only status card showing the at-rest encryption state for
  20. * OIDC client_secret and TOTP secret rows. Five severity levels:
  21. *
  22. * - Green: key configured, no legacy rows, no decryption-broken state.
  23. * - Yellow: key configured but plaintext rows still need re-encryption.
  24. * - Orange: key was auto-generated → operator must back up the key file
  25. * (or set MFA_ENCRYPTION_KEY explicitly).
  26. * - Red: encrypted rows exist but no key is loadable → recovery required.
  27. * - Grey: encryption is not configured at all and no encrypted rows exist
  28. * yet — a plain "not configured" disabled state.
  29. */
  30. export function SecurityStatusCard() {
  31. const { t } = useTranslation();
  32. const { data, isLoading, isError, refetch } = useQuery<EncryptionStatus>({
  33. queryKey: ['encryptionStatus'],
  34. queryFn: () => api.getEncryptionStatus(),
  35. // S5: bounded auto-recovery via refetchInterval backoff + manual recovery
  36. // via the "Retry" button rendered in the error branch below. Previously
  37. // a single 5xx blip killed the live status indicator until a full page
  38. // reload. The queryClient-level `retry` setting is left untouched so
  39. // operators (production) get the default 3 internal retries while tests
  40. // (which set retry:false) don't have to wait for them.
  41. refetchInterval: (query) => {
  42. if (!query.state.error) return 30_000;
  43. // After the first error, back off: 5s, 10s, 15s, then stop until the
  44. // user clicks Retry or the page reloads.
  45. const failures = query.state.fetchFailureCount ?? 0;
  46. if (failures <= 3) return Math.min(5_000 * Math.max(1, failures), 30_000);
  47. return false;
  48. },
  49. });
  50. if (isLoading) {
  51. return (
  52. <Card id="card-mfa-encryption" data-testid="encryption-status-card">
  53. <CardHeader>
  54. <div className="flex items-center gap-2">
  55. <Shield className="text-bambu-gray" size={20} />
  56. <h2 className="text-lg font-semibold">{t('settings.encryption.title')}</h2>
  57. </div>
  58. </CardHeader>
  59. <CardContent>
  60. <div className="flex items-center gap-2 text-bambu-gray" data-testid="encryption-loading">
  61. <Loader2 className="animate-spin" size={16} />
  62. <span>{t('common.loading')}</span>
  63. </div>
  64. </CardContent>
  65. </Card>
  66. );
  67. }
  68. if (isError || !data) {
  69. return (
  70. <Card id="card-mfa-encryption" data-testid="encryption-status-card">
  71. <CardHeader>
  72. <div className="flex items-center gap-2">
  73. <Shield className="text-bambu-gray" size={20} />
  74. <h2 className="text-lg font-semibold">{t('settings.encryption.title')}</h2>
  75. </div>
  76. </CardHeader>
  77. <CardContent>
  78. <div className="text-red-400" data-testid="encryption-error">{t('common.errorLoading')}</div>
  79. {/* S5: manual recovery button — the bounded auto-retry above stops
  80. after 3 consecutive failures so the operator needs an explicit
  81. way to reset polling without reloading the whole page. */}
  82. <button
  83. type="button"
  84. onClick={() => refetch()}
  85. className="mt-2 text-sm text-blue-400 underline hover:text-blue-300"
  86. data-testid="encryption-retry-button"
  87. >
  88. {t('common.retry')}
  89. </button>
  90. </CardContent>
  91. </Card>
  92. );
  93. }
  94. const totalLegacy = data.legacy_plaintext_rows.oidc_providers + data.legacy_plaintext_rows.user_totp;
  95. const totalEncrypted = data.encrypted_rows.oidc_providers + data.encrypted_rows.user_totp;
  96. // Severity selection — order matters: red first (recovery), then orange
  97. // (backup hint for auto-generated key), then yellow (legacy rows), green
  98. // (all good), grey (not configured at all and no encrypted rows).
  99. let severityClasses: string;
  100. let icon;
  101. let statusLabel: string;
  102. let statusBody: string;
  103. if (data.decryption_broken) {
  104. severityClasses = 'bg-red-500/20 border-red-500/50 text-red-400';
  105. icon = <XCircle className="text-red-400" size={20} />;
  106. statusLabel = t('settings.encryption.decryptionBrokenTitle');
  107. statusBody = t('settings.encryption.decryptionBrokenError', { count: totalEncrypted });
  108. } else if (data.key_source === 'generated') {
  109. severityClasses = 'bg-amber-500/10 border-amber-500/30 text-amber-400';
  110. icon = <ShieldCheck className="text-amber-400" size={20} />;
  111. statusLabel = t('settings.encryption.enabledGenerated');
  112. statusBody = t('settings.encryption.backupHint');
  113. } else if (totalLegacy > 0) {
  114. severityClasses = 'bg-amber-500/10 border-amber-500/30 text-amber-400';
  115. icon = <AlertTriangle className="text-amber-400" size={20} />;
  116. statusLabel = data.key_source === 'env' ? t('settings.encryption.enabledFromEnv') : t('settings.encryption.enabledFromFile');
  117. statusBody = t('settings.encryption.legacyRowsWarning', { count: totalLegacy });
  118. } else if (data.key_configured) {
  119. severityClasses = 'bg-green-500/20 border-green-500/30 text-green-400';
  120. icon = <ShieldCheck className="text-green-400" size={20} />;
  121. statusLabel = data.key_source === 'env' ? t('settings.encryption.enabledFromEnv') : t('settings.encryption.enabledFromFile');
  122. statusBody = t('settings.encryption.allEncrypted');
  123. } else {
  124. severityClasses = 'bg-gray-500/20 border-gray-500/30 text-gray-400';
  125. icon = <ShieldOff className="text-gray-400" size={20} />;
  126. statusLabel = t('settings.encryption.notConfigured');
  127. statusBody = t('settings.encryption.notConfiguredDesc');
  128. }
  129. // E4: show legacy-rows warning as a secondary alert when key is auto-generated
  130. // AND there are still unencrypted rows (both conditions can be true simultaneously).
  131. const showConcurrentLegacyWarning = data.key_source === 'generated' && totalLegacy > 0;
  132. return (
  133. <Card id="card-mfa-encryption" data-testid="encryption-status-card">
  134. <CardHeader>
  135. <div className="flex items-center gap-2">
  136. {icon}
  137. <h2 className="text-lg font-semibold">{t('settings.encryption.title')}</h2>
  138. </div>
  139. </CardHeader>
  140. <CardContent>
  141. <div
  142. className={`p-3 border rounded-lg ${severityClasses}`}
  143. data-testid="encryption-status"
  144. >
  145. <p className="font-medium mb-1">{statusLabel}</p>
  146. <p className="text-sm">{statusBody}</p>
  147. </div>
  148. {showConcurrentLegacyWarning && (
  149. <div
  150. className="mt-2 p-3 border rounded-lg bg-amber-500/10 border-amber-500/30 text-amber-400"
  151. data-testid="encryption-legacy-warning"
  152. >
  153. <p className="text-sm">{t('settings.encryption.legacyRowsWarning', { count: totalLegacy })}</p>
  154. </div>
  155. )}
  156. {data.migration_error_count > 0 && (
  157. <div
  158. className="mt-2 p-3 border rounded-lg bg-amber-500/10 border-amber-500/30 text-amber-400"
  159. data-testid="encryption-migration-warning"
  160. >
  161. <p className="text-sm">
  162. {t('settings.encryption.migrationErrorWarning', { count: data.migration_error_count })}
  163. </p>
  164. </div>
  165. )}
  166. <div className="mt-4 grid grid-cols-2 gap-4 text-sm">
  167. <div>
  168. <p className="text-bambu-gray">{t('settings.encryption.encryptedRowsLabel')}</p>
  169. <p className="font-medium">
  170. OIDC: {data.encrypted_rows.oidc_providers} · TOTP: {data.encrypted_rows.user_totp}
  171. </p>
  172. </div>
  173. <div>
  174. <p className="text-bambu-gray">{t('settings.encryption.legacyRowsLabel')}</p>
  175. <p className="font-medium">
  176. OIDC: {data.legacy_plaintext_rows.oidc_providers} · TOTP: {data.legacy_plaintext_rows.user_totp}
  177. </p>
  178. </div>
  179. </div>
  180. </CardContent>
  181. </Card>
  182. );
  183. }