TwoFactorSettings.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import { useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { ShieldCheck, ShieldOff, Mail, Smartphone, Key, RefreshCw, Trash2, X, Eye, EyeOff, Copy } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import { Card, CardContent, CardHeader } from './Card';
  7. import { Button } from './Button';
  8. import { useToast } from '../contexts/ToastContext';
  9. import { useAuth } from '../contexts/AuthContext';
  10. // ─── Small reusable code input ────────────────────────────────────────────────
  11. function CodeInput({
  12. value,
  13. onChange,
  14. placeholder,
  15. maxLength = 6,
  16. }: {
  17. value: string;
  18. onChange: (v: string) => void;
  19. placeholder?: string;
  20. maxLength?: number;
  21. }) {
  22. return (
  23. <input
  24. type="text"
  25. value={value}
  26. onChange={(e) => onChange(e.target.value.toUpperCase().replace(/\s/g, ''))}
  27. maxLength={maxLength}
  28. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors font-mono tracking-widest text-center"
  29. placeholder={placeholder}
  30. autoComplete="one-time-code"
  31. />
  32. );
  33. }
  34. // ─── Backup codes display ─────────────────────────────────────────────────────
  35. function BackupCodesDisplay({ codes, onDone }: { codes: string[]; onDone: () => void }) {
  36. const { t } = useTranslation();
  37. const [copied, setCopied] = useState(false);
  38. const handleCopy = () => {
  39. navigator.clipboard.writeText(codes.join('\n'));
  40. setCopied(true);
  41. setTimeout(() => setCopied(false), 2000);
  42. };
  43. return (
  44. <div className="space-y-4">
  45. <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
  46. <p className="text-amber-400 text-sm font-medium">{t('settings.twoFa.backupCodesWarning')}</p>
  47. </div>
  48. <div className="grid grid-cols-2 gap-2">
  49. {codes.map((code, index) => (
  50. <code key={index} className="bg-bambu-dark-secondary rounded px-3 py-2 text-center font-mono text-sm text-white tracking-widest">
  51. {code}
  52. </code>
  53. ))}
  54. </div>
  55. <div className="flex gap-3">
  56. <Button variant="secondary" size="sm" onClick={handleCopy} className="flex items-center gap-2">
  57. <Copy className="w-4 h-4" />
  58. {copied ? t('common.copied') : t('common.copy')}
  59. </Button>
  60. <Button variant="primary" size="sm" onClick={onDone} className="flex-1">
  61. {t('settings.twoFa.savedCodes')}
  62. </Button>
  63. </div>
  64. </div>
  65. );
  66. }
  67. // ─── TOTP setup wizard ────────────────────────────────────────────────────────
  68. function TOTPSetupWizard({ onDone }: { onDone: () => void }) {
  69. const { t } = useTranslation();
  70. const queryClient = useQueryClient();
  71. const { showToast } = useToast();
  72. const [step, setStep] = useState<'qr' | 'confirm' | 'backup'>('qr');
  73. const [code, setCode] = useState('');
  74. const [backupCodes, setBackupCodes] = useState<string[]>([]);
  75. const [showSecret, setShowSecret] = useState(false);
  76. const { data: setupData, isLoading } = useQuery({
  77. queryKey: ['totp-setup'],
  78. queryFn: () => api.setupTOTP(),
  79. staleTime: Infinity,
  80. });
  81. const enableMutation = useMutation({
  82. mutationFn: (c: string) => api.enableTOTP(c),
  83. onSuccess: (data) => {
  84. setBackupCodes(data.backup_codes);
  85. setStep('backup');
  86. queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
  87. },
  88. onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
  89. });
  90. if (isLoading || !setupData) {
  91. return (
  92. <div className="flex items-center justify-center py-8">
  93. <RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
  94. </div>
  95. );
  96. }
  97. if (step === 'qr') {
  98. return (
  99. <div className="space-y-4">
  100. <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.setupInstructions')}</p>
  101. <div className="flex justify-center">
  102. <img
  103. src={`data:image/png;base64,${setupData.qr_code_b64}`}
  104. alt="TOTP QR Code"
  105. className="w-48 h-48 rounded-lg"
  106. />
  107. </div>
  108. <div>
  109. <p className="text-xs text-bambu-gray mb-1">{t('settings.twoFa.manualEntry')}</p>
  110. <div className="flex items-center gap-2 bg-bambu-dark-secondary rounded-lg px-3 py-2">
  111. <code className="text-white text-xs font-mono flex-1 break-all">
  112. {showSecret ? setupData.secret : '••••••••••••••••'}
  113. </code>
  114. <button onClick={() => setShowSecret(!showSecret)} className="text-bambu-gray hover:text-white">
  115. {showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
  116. </button>
  117. <button
  118. onClick={() => { navigator.clipboard.writeText(setupData.secret); }}
  119. className="text-bambu-gray hover:text-white"
  120. >
  121. <Copy className="w-4 h-4" />
  122. </button>
  123. </div>
  124. </div>
  125. <Button variant="primary" className="w-full" onClick={() => setStep('confirm')}>
  126. {t('settings.twoFa.scannedContinue')}
  127. </Button>
  128. </div>
  129. );
  130. }
  131. if (step === 'confirm') {
  132. return (
  133. <div className="space-y-4">
  134. <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.enterCodeToConfirm')}</p>
  135. <CodeInput value={code} onChange={setCode} placeholder="000000" />
  136. <div className="flex gap-3">
  137. <Button variant="secondary" onClick={() => setStep('qr')} className="flex-1">
  138. {t('common.back')}
  139. </Button>
  140. <Button
  141. variant="primary"
  142. className="flex-1"
  143. disabled={code.length !== 6 || enableMutation.isPending}
  144. onClick={() => enableMutation.mutate(code)}
  145. >
  146. {enableMutation.isPending ? t('common.saving') : t('settings.twoFa.activate')}
  147. </Button>
  148. </div>
  149. </div>
  150. );
  151. }
  152. // step === 'backup'
  153. return (
  154. <div className="space-y-4">
  155. <h3 className="text-white font-medium">{t('settings.twoFa.backupCodesTitle')}</h3>
  156. <BackupCodesDisplay codes={backupCodes} onDone={onDone} />
  157. </div>
  158. );
  159. }
  160. // ─── Main component ───────────────────────────────────────────────────────────
  161. export function TwoFactorSettings() {
  162. const { t } = useTranslation();
  163. const queryClient = useQueryClient();
  164. const { showToast } = useToast();
  165. const { user } = useAuth();
  166. const [showTOTPSetup, setShowTOTPSetup] = useState(false);
  167. const [showDisableTOTP, setShowDisableTOTP] = useState(false);
  168. const [showRegenBackup, setShowRegenBackup] = useState(false);
  169. const [disableCode, setDisableCode] = useState('');
  170. const [regenCode, setRegenCode] = useState('');
  171. const [newBackupCodes, setNewBackupCodes] = useState<string[] | null>(null);
  172. // Email OTP enable: two-step proof-of-possession flow
  173. const [emailSetupToken, setEmailSetupToken] = useState<string | null>(null);
  174. const [emailSetupCode, setEmailSetupCode] = useState('');
  175. // Email OTP disable: requires account password
  176. const [showDisableEmail, setShowDisableEmail] = useState(false);
  177. const [emailDisablePassword, setEmailDisablePassword] = useState('');
  178. const [showEmailDisablePassword, setShowEmailDisablePassword] = useState(false);
  179. const { data: status, isLoading } = useQuery({
  180. queryKey: ['2fa-status'],
  181. queryFn: () => api.get2FAStatus(),
  182. });
  183. const { data: oidcLinks } = useQuery({
  184. queryKey: ['oidc-links'],
  185. queryFn: () => api.getOIDCLinks(),
  186. });
  187. // Step 1: request verification code (proof of possession)
  188. const enableEmailRequestMutation = useMutation({
  189. mutationFn: () => api.enableEmailOTP(),
  190. onSuccess: (data: { message: string; setup_token: string }) => {
  191. setEmailSetupToken(data.setup_token);
  192. showToast(data.message, 'success');
  193. },
  194. onError: (e: Error) => {
  195. const msg = e.message ?? '';
  196. if (msg.toLowerCase().includes('smtp')) {
  197. showToast(t('settings.twoFa.smtpRequired'), 'error');
  198. } else {
  199. showToast(msg, 'error');
  200. }
  201. },
  202. });
  203. // Step 2: confirm with the code received by email
  204. const enableEmailConfirmMutation = useMutation({
  205. mutationFn: () => api.confirmEnableEmailOTP(emailSetupToken!, emailSetupCode),
  206. onSuccess: () => {
  207. queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
  208. setEmailSetupToken(null);
  209. setEmailSetupCode('');
  210. showToast(t('settings.twoFa.emailOtpEnabled'), 'success');
  211. },
  212. onError: (e: Error) => showToast(e.message, 'error'),
  213. });
  214. const disableEmailMutation = useMutation({
  215. mutationFn: (password: string) => api.disableEmailOTP(password),
  216. onSuccess: () => {
  217. queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
  218. setShowDisableEmail(false);
  219. setEmailDisablePassword('');
  220. showToast(t('settings.twoFa.emailOtpDisabled'), 'success');
  221. },
  222. onError: (e: Error) => showToast(e.message, 'error'),
  223. });
  224. const disableTOTPMutation = useMutation({
  225. mutationFn: (code: string) => api.disableTOTP(code),
  226. onSuccess: () => {
  227. queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
  228. setShowDisableTOTP(false);
  229. setDisableCode('');
  230. showToast(t('settings.twoFa.totpDisabled'), 'success');
  231. },
  232. onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
  233. });
  234. const regenMutation = useMutation({
  235. mutationFn: (code: string) => api.regenerateBackupCodes(code),
  236. onSuccess: (data) => {
  237. queryClient.invalidateQueries({ queryKey: ['2fa-status'] });
  238. setShowRegenBackup(false);
  239. setRegenCode('');
  240. setNewBackupCodes(data.backup_codes);
  241. },
  242. onError: () => showToast(t('settings.twoFa.invalidCode'), 'error'),
  243. });
  244. const unlinkOIDCMutation = useMutation({
  245. mutationFn: (providerId: number) => api.deleteOIDCLink(providerId),
  246. onSuccess: () => {
  247. queryClient.invalidateQueries({ queryKey: ['oidc-links'] });
  248. showToast(t('settings.twoFa.oidcUnlinked'), 'success');
  249. },
  250. onError: (e: Error) => showToast(e.message, 'error'),
  251. });
  252. if (isLoading) {
  253. return (
  254. <div className="flex items-center justify-center py-12">
  255. <RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
  256. </div>
  257. );
  258. }
  259. const hasEmail = !!user?.email;
  260. return (
  261. <div className="space-y-6">
  262. {/* ── TOTP ─────────────────────────────────────────────────────────── */}
  263. <Card>
  264. <CardHeader>
  265. <div className="flex items-center gap-3">
  266. <div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.totp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
  267. <Smartphone className={`w-5 h-5 ${status?.totp_enabled ? 'text-green-400' : 'text-gray-400'}`} />
  268. </div>
  269. <div>
  270. <h3 className="text-white font-semibold">{t('settings.twoFa.totpTitle')}</h3>
  271. <p className="text-bambu-gray text-sm">{t('settings.twoFa.totpDesc')}</p>
  272. </div>
  273. <div className="ml-auto">
  274. {status?.totp_enabled ? (
  275. <span className="flex items-center gap-1 text-green-400 text-sm font-medium">
  276. <ShieldCheck className="w-4 h-4" /> {t('common.enabled')}
  277. </span>
  278. ) : (
  279. <span className="flex items-center gap-1 text-bambu-gray text-sm">
  280. <ShieldOff className="w-4 h-4" /> {t('common.disabled')}
  281. </span>
  282. )}
  283. </div>
  284. </div>
  285. </CardHeader>
  286. <CardContent>
  287. {/* TOTP Setup wizard */}
  288. {showTOTPSetup ? (
  289. <div className="space-y-4">
  290. <div className="flex items-center justify-between mb-2">
  291. <h4 className="text-white font-medium">{t('settings.twoFa.setupAuthApp')}</h4>
  292. <button onClick={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} className="text-bambu-gray hover:text-white">
  293. <X className="w-5 h-5" />
  294. </button>
  295. </div>
  296. <TOTPSetupWizard onDone={() => { setShowTOTPSetup(false); queryClient.removeQueries({ queryKey: ['totp-setup'] }); }} />
  297. </div>
  298. ) : showDisableTOTP ? (
  299. <div className="space-y-4">
  300. <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.disableConfirmHint')}</p>
  301. <CodeInput value={disableCode} onChange={setDisableCode} placeholder="000000 or XXXXXXXX" maxLength={8} />
  302. <div className="flex gap-3">
  303. <Button variant="secondary" onClick={() => { setShowDisableTOTP(false); setDisableCode(''); }} className="flex-1">
  304. {t('common.cancel')}
  305. </Button>
  306. <Button
  307. variant="danger"
  308. className="flex-1"
  309. disabled={disableCode.length < 6 || disableTOTPMutation.isPending}
  310. onClick={() => disableTOTPMutation.mutate(disableCode)}
  311. >
  312. {disableTOTPMutation.isPending ? t('common.saving') : t('settings.twoFa.disableTotp')}
  313. </Button>
  314. </div>
  315. </div>
  316. ) : showRegenBackup ? (
  317. <div className="space-y-4">
  318. <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.regenBackupHint')}</p>
  319. <CodeInput value={regenCode} onChange={setRegenCode} placeholder="000000 or XXXXXXXX" maxLength={8} />
  320. <div className="flex gap-3">
  321. <Button variant="secondary" onClick={() => { setShowRegenBackup(false); setRegenCode(''); }} className="flex-1">
  322. {t('common.cancel')}
  323. </Button>
  324. <Button
  325. variant="primary"
  326. className="flex-1"
  327. disabled={regenCode.length < 6 || regenMutation.isPending}
  328. onClick={() => regenMutation.mutate(regenCode)}
  329. >
  330. {regenMutation.isPending ? t('common.saving') : t('settings.twoFa.regenBackup')}
  331. </Button>
  332. </div>
  333. </div>
  334. ) : newBackupCodes ? (
  335. <div className="space-y-4">
  336. <h4 className="text-white font-medium">{t('settings.twoFa.newBackupCodes')}</h4>
  337. <BackupCodesDisplay codes={newBackupCodes} onDone={() => setNewBackupCodes(null)} />
  338. </div>
  339. ) : (
  340. <div className="space-y-3">
  341. {!status?.totp_enabled ? (
  342. <Button variant="primary" onClick={() => setShowTOTPSetup(true)} className="flex items-center gap-2">
  343. <Smartphone className="w-4 h-4" />
  344. {t('settings.twoFa.setupTotp')}
  345. </Button>
  346. ) : (
  347. <div className="flex flex-wrap gap-3">
  348. <div className="flex items-center gap-2 text-sm text-bambu-gray-light">
  349. <Key className="w-4 h-4" />
  350. {t('settings.twoFa.backupCodesRemaining', { count: status.backup_codes_remaining })}
  351. </div>
  352. <Button variant="secondary" size="sm" onClick={() => setShowRegenBackup(true)} className="flex items-center gap-2">
  353. <RefreshCw className="w-4 h-4" />
  354. {t('settings.twoFa.regenBackup')}
  355. </Button>
  356. <Button variant="danger" size="sm" onClick={() => setShowDisableTOTP(true)} className="flex items-center gap-2">
  357. <Trash2 className="w-4 h-4" />
  358. {t('settings.twoFa.disableTotp')}
  359. </Button>
  360. </div>
  361. )}
  362. </div>
  363. )}
  364. </CardContent>
  365. </Card>
  366. {/* ── Email OTP ─────────────────────────────────────────────────────── */}
  367. <Card>
  368. <CardHeader>
  369. <div className="flex items-center gap-3">
  370. <div className={`w-10 h-10 rounded-full flex items-center justify-center ${status?.email_otp_enabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
  371. <Mail className={`w-5 h-5 ${status?.email_otp_enabled ? 'text-green-400' : 'text-gray-400'}`} />
  372. </div>
  373. <div className="flex-1">
  374. <h3 className="text-white font-semibold">{t('settings.twoFa.emailOtpTitle')}</h3>
  375. <p className="text-bambu-gray text-sm">
  376. {hasEmail
  377. ? t('settings.twoFa.emailOtpDesc', { email: user?.email })
  378. : t('settings.twoFa.emailOtpNoEmail')}
  379. </p>
  380. </div>
  381. {/* Show status badge; enable/disable handled in CardContent */}
  382. <div className="ml-auto">
  383. {status?.email_otp_enabled ? (
  384. <span className="flex items-center gap-1 text-green-400 text-sm font-medium">
  385. <ShieldCheck className="w-4 h-4" /> {t('common.enabled')}
  386. </span>
  387. ) : (
  388. <span className="flex items-center gap-1 text-bambu-gray text-sm">
  389. <ShieldOff className="w-4 h-4" /> {t('common.disabled')}
  390. </span>
  391. )}
  392. </div>
  393. </div>
  394. </CardHeader>
  395. <CardContent>
  396. {!hasEmail ? (
  397. <p className="text-amber-400 text-sm">{t('settings.twoFa.addEmailFirst')}</p>
  398. ) : emailSetupToken ? (
  399. /* Step 2: enter the code that was sent to the email */
  400. <div className="space-y-4">
  401. <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.emailSetupEnterCode')}</p>
  402. <CodeInput value={emailSetupCode} onChange={setEmailSetupCode} placeholder="000000" />
  403. <div className="flex gap-3">
  404. <Button
  405. variant="secondary"
  406. onClick={() => { setEmailSetupToken(null); setEmailSetupCode(''); }}
  407. className="flex-1"
  408. >
  409. {t('common.cancel')}
  410. </Button>
  411. <Button
  412. variant="primary"
  413. className="flex-1"
  414. disabled={emailSetupCode.length !== 6 || enableEmailConfirmMutation.isPending}
  415. onClick={() => enableEmailConfirmMutation.mutate()}
  416. >
  417. {enableEmailConfirmMutation.isPending ? t('common.saving') : t('settings.twoFa.verifyAndEnable')}
  418. </Button>
  419. </div>
  420. </div>
  421. ) : showDisableEmail ? (
  422. /* Disable: require account password for re-auth */
  423. <div className="space-y-4">
  424. <p className="text-bambu-gray-light text-sm">{t('settings.twoFa.emailDisablePasswordHint')}</p>
  425. <div className="relative">
  426. <input
  427. type={showEmailDisablePassword ? 'text' : 'password'}
  428. value={emailDisablePassword}
  429. onChange={(e) => setEmailDisablePassword(e.target.value)}
  430. placeholder={t('settings.twoFa.passwordPlaceholder')}
  431. className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
  432. />
  433. <button
  434. type="button"
  435. onClick={() => setShowEmailDisablePassword(!showEmailDisablePassword)}
  436. className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
  437. >
  438. {showEmailDisablePassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
  439. </button>
  440. </div>
  441. <div className="flex gap-3">
  442. <Button
  443. variant="secondary"
  444. onClick={() => { setShowDisableEmail(false); setEmailDisablePassword(''); }}
  445. className="flex-1"
  446. >
  447. {t('common.cancel')}
  448. </Button>
  449. <Button
  450. variant="danger"
  451. className="flex-1"
  452. disabled={!emailDisablePassword || disableEmailMutation.isPending}
  453. onClick={() => disableEmailMutation.mutate(emailDisablePassword)}
  454. >
  455. {disableEmailMutation.isPending ? t('common.saving') : t('settings.twoFa.disableEmailOtp')}
  456. </Button>
  457. </div>
  458. </div>
  459. ) : (
  460. <div className="flex gap-3">
  461. {!status?.email_otp_enabled ? (
  462. <Button
  463. variant="primary"
  464. disabled={!hasEmail || enableEmailRequestMutation.isPending}
  465. onClick={() => enableEmailRequestMutation.mutate()}
  466. className="flex items-center gap-2"
  467. >
  468. <Mail className="w-4 h-4" />
  469. {enableEmailRequestMutation.isPending ? t('common.saving') : t('settings.twoFa.enableEmailOtp')}
  470. </Button>
  471. ) : (
  472. <Button
  473. variant="danger"
  474. size="sm"
  475. onClick={() => setShowDisableEmail(true)}
  476. className="flex items-center gap-2"
  477. >
  478. <Trash2 className="w-4 h-4" />
  479. {t('settings.twoFa.disableEmailOtp')}
  480. </Button>
  481. )}
  482. </div>
  483. )}
  484. </CardContent>
  485. </Card>
  486. {/* ── Linked SSO accounts ───────────────────────────────────────────── */}
  487. {oidcLinks && oidcLinks.length > 0 && (
  488. <Card>
  489. <CardHeader>
  490. <h3 className="text-white font-semibold">{t('settings.twoFa.linkedAccounts')}</h3>
  491. <p className="text-bambu-gray text-sm">{t('settings.twoFa.linkedAccountsDesc')}</p>
  492. </CardHeader>
  493. <CardContent>
  494. <div className="space-y-3">
  495. {oidcLinks.map((link) => (
  496. <div key={link.id} className="flex items-center justify-between py-2 border-b border-bambu-dark-tertiary last:border-0">
  497. <div>
  498. <p className="text-white text-sm font-medium">{link.provider_name}</p>
  499. {link.provider_email && (
  500. <p className="text-bambu-gray text-xs">{link.provider_email}</p>
  501. )}
  502. </div>
  503. <Button
  504. variant="danger"
  505. size="sm"
  506. onClick={() => unlinkOIDCMutation.mutate(link.provider_id)}
  507. disabled={unlinkOIDCMutation.isPending}
  508. >
  509. <Trash2 className="w-4 h-4" />
  510. </Button>
  511. </div>
  512. ))}
  513. </div>
  514. </CardContent>
  515. </Card>
  516. )}
  517. </div>
  518. );
  519. }