OIDCProviderSettings.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. import { useState, type ReactNode } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import type { Group, OIDCProvider, OIDCProviderCreate } from '../api/client';
  7. import { Card, CardContent, CardHeader } from './Card';
  8. import { Button } from './Button';
  9. import { Toggle } from './Toggle';
  10. import { ConfirmModal } from './ConfirmModal';
  11. import { useToast } from '../contexts/ToastContext';
  12. const EMPTY_FORM: OIDCProviderCreate = {
  13. name: '',
  14. issuer_url: '',
  15. client_id: '',
  16. client_secret: '',
  17. scopes: 'openid email profile',
  18. is_enabled: true,
  19. auto_create_users: false,
  20. auto_link_existing_accounts: false,
  21. email_claim: 'email',
  22. require_email_verified: true,
  23. icon_url: undefined,
  24. default_group_id: null,
  25. };
  26. // ─── Provider form (create / edit) ───────────────────────────────────────────
  27. function ProviderForm({
  28. initial,
  29. isEdit = false,
  30. groups = [],
  31. onSave,
  32. onCancel,
  33. isPending,
  34. }: {
  35. initial: OIDCProviderCreate;
  36. isEdit?: boolean;
  37. groups?: Group[];
  38. onSave: (data: OIDCProviderCreate) => void;
  39. onCancel: () => void;
  40. isPending: boolean;
  41. }) {
  42. const { t } = useTranslation();
  43. const [form, setForm] = useState<OIDCProviderCreate>(initial);
  44. const [secretChanged, setSecretChanged] = useState(false);
  45. const set = (key: keyof OIDCProviderCreate, value: unknown) =>
  46. setForm((prev) => ({ ...prev, [key]: value }));
  47. const inputCls =
  48. '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 text-sm';
  49. const labelCls = 'block text-sm font-medium text-white mb-1';
  50. const handleSave = () => {
  51. const payload = { ...form };
  52. if (isEdit && !secretChanged) {
  53. delete (payload as Partial<OIDCProviderCreate>).client_secret;
  54. }
  55. onSave(payload);
  56. };
  57. const autoLinkOn = form.auto_link_existing_accounts === true;
  58. const emailVerifiedOn = form.require_email_verified ?? true;
  59. let requireEmailVerifiedDesc: ReactNode;
  60. if (autoLinkOn) {
  61. requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedAutoLink');
  62. } else if (emailVerifiedOn) {
  63. requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedDesc');
  64. } else {
  65. requireEmailVerifiedDesc = (
  66. <span className="text-red-400">{t('settings.oidc.form.requireEmailVerifiedWarning')}</span>
  67. );
  68. }
  69. return (
  70. <div className="space-y-4">
  71. <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
  72. <div>
  73. <label className={labelCls}>{t('settings.oidc.form.name')} <span className="text-red-400">*</span></label>
  74. <input className={inputCls} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder="Google" />
  75. </div>
  76. <div>
  77. <label className={labelCls}>{t('settings.oidc.form.issuerUrl')} <span className="text-red-400">*</span></label>
  78. <input className={inputCls} value={form.issuer_url} onChange={(e) => set('issuer_url', e.target.value)} placeholder="https://accounts.google.com" />
  79. </div>
  80. <div>
  81. <label className={labelCls}>{t('settings.oidc.form.clientId')} <span className="text-red-400">*</span></label>
  82. <input className={inputCls} value={form.client_id} onChange={(e) => set('client_id', e.target.value)} placeholder="your-client-id" />
  83. </div>
  84. <div>
  85. <label className={labelCls}>
  86. {t('settings.oidc.form.clientSecret')}
  87. {!isEdit && <span className="text-red-400"> *</span>}
  88. {isEdit && <span className="text-bambu-gray text-xs ml-1">({t('settings.oidc.form.secretHint')})</span>}
  89. </label>
  90. <input
  91. className={inputCls}
  92. type="password"
  93. value={secretChanged ? form.client_secret : ''}
  94. placeholder={isEdit && !secretChanged ? '••••••••' : t('settings.oidc.form.secretPlaceholder')}
  95. onChange={(e) => {
  96. setSecretChanged(true);
  97. set('client_secret', e.target.value);
  98. }}
  99. />
  100. </div>
  101. <div>
  102. <label className={labelCls}>{t('settings.oidc.form.scopes')}</label>
  103. <input className={inputCls} value={form.scopes} onChange={(e) => set('scopes', e.target.value)} placeholder="openid email profile" />
  104. </div>
  105. <div>
  106. <label className={labelCls}>{t('settings.oidc.form.iconUrl')}</label>
  107. <input
  108. className={inputCls}
  109. value={form.icon_url ?? ''}
  110. onChange={(e) => set('icon_url', e.target.value === '' ? null : e.target.value)}
  111. placeholder="https://..."
  112. />
  113. </div>
  114. </div>
  115. <div className="flex flex-wrap gap-6 pt-2">
  116. <label className="flex items-center gap-3 cursor-pointer">
  117. <Toggle checked={form.is_enabled ?? true} onChange={(v) => set('is_enabled', v)} />
  118. <span className="text-white text-sm">{t('settings.oidc.form.enabled')}</span>
  119. </label>
  120. <label className="flex items-center gap-3 cursor-pointer">
  121. <Toggle checked={form.auto_create_users ?? false} onChange={(v) => set('auto_create_users', v)} />
  122. <div>
  123. <p className="text-white text-sm">{t('settings.oidc.form.autoCreate')}</p>
  124. <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoCreateDesc')}</p>
  125. </div>
  126. </label>
  127. <label className="flex items-center gap-3 cursor-pointer w-full">
  128. <Toggle checked={form.auto_link_existing_accounts ?? false} onChange={(v) => set('auto_link_existing_accounts', v)} />
  129. <div>
  130. <p className="text-white text-sm">{t('settings.oidc.form.autoLink')}</p>
  131. <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoLinkDesc')}</p>
  132. </div>
  133. </label>
  134. <label className="flex items-center gap-3 cursor-pointer w-full">
  135. <Toggle
  136. checked={emailVerifiedOn}
  137. onChange={(v) => set('require_email_verified', v)}
  138. disabled={autoLinkOn}
  139. />
  140. <div>
  141. <p className="text-white text-sm">{t('settings.oidc.form.requireEmailVerified')}</p>
  142. <p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
  143. </div>
  144. </label>
  145. </div>
  146. <div>
  147. <label className={labelCls}>{t('settings.oidc.form.emailClaim')}</label>
  148. <input
  149. className={inputCls}
  150. value={form.email_claim}
  151. onChange={(e) => set('email_claim', e.target.value || 'email')}
  152. placeholder={t('settings.oidc.form.emailClaimPlaceholder')}
  153. />
  154. <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.emailClaimDesc')}</p>
  155. {autoLinkOn && form.email_claim !== 'email' && (
  156. <p className="text-yellow-400 text-xs mt-1">{t('settings.oidc.form.emailClaimCustomClaimAutoLinkWarning')}</p>
  157. )}
  158. </div>
  159. <div>
  160. <label className={labelCls}>{t('settings.oidc.form.defaultGroup')}</label>
  161. <select
  162. className={inputCls}
  163. value={form.default_group_id ?? ''}
  164. onChange={(e) => set('default_group_id', e.target.value ? Number(e.target.value) : null)}
  165. >
  166. <option value="">{t('settings.oidc.form.defaultGroupViewersFallback')}</option>
  167. {groups.map((g) => (
  168. <option key={g.id} value={g.id}>{g.name}</option>
  169. ))}
  170. </select>
  171. <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.defaultGroupDesc')}</p>
  172. </div>
  173. <div className="flex gap-3 pt-2">
  174. <Button variant="secondary" onClick={onCancel} className="flex-1">
  175. {t('common.cancel')}
  176. </Button>
  177. <Button
  178. variant="primary"
  179. className="flex-1"
  180. disabled={!form.name || !form.issuer_url || !form.client_id || (!isEdit && !form.client_secret) || (isEdit && secretChanged && !form.client_secret) || isPending}
  181. onClick={handleSave}
  182. >
  183. {isPending ? t('common.saving') : t('common.save')}
  184. </Button>
  185. </div>
  186. </div>
  187. );
  188. }
  189. /**
  190. * Per-provider icon avatar in the admin Settings list (#1333 review).
  191. *
  192. * Extracted so each card has its own `iconFailed` state. Previously
  193. * `onError` just set `display: none` and the admin saw an unexplained gap
  194. * where the icon should be — now we swap in the Globe fallback exactly
  195. * like the `has_icon === false` branch, so the visual state is
  196. * self-explanatory regardless of why the icon didn't load.
  197. */
  198. function ProviderIconAvatar({ provider }: { provider: OIDCProvider }) {
  199. const [iconFailed, setIconFailed] = useState(false);
  200. const showIcon = provider.has_icon && !iconFailed;
  201. if (showIcon) {
  202. return (
  203. <img
  204. src={api.oidcProviderIconUrl(provider.id)}
  205. alt={provider.name}
  206. className="w-8 h-8 rounded object-contain"
  207. onError={() => setIconFailed(true)}
  208. />
  209. );
  210. }
  211. return (
  212. <div className="w-8 h-8 rounded-full bg-bambu-dark-tertiary flex items-center justify-center">
  213. <Globe className="w-4 h-4 text-bambu-gray" />
  214. </div>
  215. );
  216. }
  217. // ─── Main component ───────────────────────────────────────────────────────────
  218. export function OIDCProviderSettings() {
  219. const { t } = useTranslation();
  220. const queryClient = useQueryClient();
  221. const { showToast } = useToast();
  222. const [showCreate, setShowCreate] = useState(false);
  223. const [editingId, setEditingId] = useState<number | null>(null);
  224. const [deleteTarget, setDeleteTarget] = useState<OIDCProvider | null>(null);
  225. const { data: providers, isLoading } = useQuery({
  226. queryKey: ['oidc-providers-all'],
  227. queryFn: () => api.getOIDCProvidersAll(),
  228. });
  229. const { data: groups = [] } = useQuery({
  230. queryKey: ['groups'],
  231. queryFn: () => api.getGroups(),
  232. });
  233. const createMutation = useMutation({
  234. mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),
  235. onSuccess: () => {
  236. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  237. setShowCreate(false);
  238. showToast(t('settings.oidc.created'), 'success');
  239. },
  240. onError: (e: Error) => showToast(e.message, 'error'),
  241. });
  242. const updateMutation = useMutation({
  243. mutationFn: ({ id, data }: { id: number; data: Partial<OIDCProviderCreate> }) =>
  244. api.updateOIDCProvider(id, data),
  245. onSuccess: () => {
  246. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  247. setEditingId(null);
  248. showToast(t('settings.oidc.updated'), 'success');
  249. },
  250. onError: (e: Error) => showToast(e.message, 'error'),
  251. });
  252. const deleteMutation = useMutation({
  253. mutationFn: (id: number) => api.deleteOIDCProvider(id),
  254. onSuccess: () => {
  255. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  256. setDeleteTarget(null);
  257. showToast(t('settings.oidc.deleted'), 'success');
  258. },
  259. onError: (e: Error) => showToast(e.message, 'error'),
  260. });
  261. // Icon-proxy mutations (#1333). Refresh re-fetches from the stored
  262. // icon_url; remove clears the cached bytes but keeps icon_url.
  263. const refreshIconMutation = useMutation({
  264. mutationFn: (id: number) => api.refreshOIDCProviderIcon(id),
  265. onSuccess: () => {
  266. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  267. queryClient.invalidateQueries({ queryKey: ['oidc-providers'] });
  268. showToast(t('settings.oidc.iconRefreshed'), 'success');
  269. },
  270. onError: (e: Error) => showToast(e.message || t('settings.oidc.iconFetchFailed'), 'error'),
  271. });
  272. const removeIconMutation = useMutation({
  273. mutationFn: (id: number) => api.deleteOIDCProviderIcon(id),
  274. onSuccess: () => {
  275. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  276. queryClient.invalidateQueries({ queryKey: ['oidc-providers'] });
  277. showToast(t('settings.oidc.iconRemoved'), 'success');
  278. },
  279. onError: (e: Error) => showToast(e.message, 'error'),
  280. });
  281. const toggleEnabled = (provider: OIDCProvider) =>
  282. updateMutation.mutate({ id: provider.id, data: { is_enabled: !provider.is_enabled } });
  283. if (isLoading) {
  284. return (
  285. <div className="flex items-center justify-center py-12">
  286. <RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
  287. </div>
  288. );
  289. }
  290. return (
  291. <div className="space-y-6">
  292. {/* Header */}
  293. <Card id="card-oidc">
  294. <CardHeader>
  295. <div className="flex items-center justify-between">
  296. <div>
  297. <h3 className="text-white font-semibold">{t('settings.oidc.title')}</h3>
  298. <p className="text-bambu-gray text-sm">{t('settings.oidc.desc')}</p>
  299. </div>
  300. {!showCreate && (
  301. <Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="flex items-center gap-2">
  302. <Plus className="w-4 h-4" />
  303. {t('settings.oidc.addProvider')}
  304. </Button>
  305. )}
  306. </div>
  307. </CardHeader>
  308. {showCreate && (
  309. <CardContent>
  310. <div className="border-t border-bambu-dark-tertiary pt-4">
  311. <h4 className="text-white font-medium mb-4">{t('settings.oidc.newProvider')}</h4>
  312. <ProviderForm
  313. initial={EMPTY_FORM}
  314. groups={groups}
  315. onSave={(data) => createMutation.mutate(data)}
  316. onCancel={() => setShowCreate(false)}
  317. isPending={createMutation.isPending}
  318. />
  319. </div>
  320. </CardContent>
  321. )}
  322. </Card>
  323. {/* Provider list */}
  324. {providers && providers.length === 0 && !showCreate && (
  325. <Card id="card-oidc-empty">
  326. <CardContent>
  327. <div className="text-center py-8 space-y-3">
  328. <Globe className="w-12 h-12 text-bambu-gray mx-auto" />
  329. <p className="text-bambu-gray">{t('settings.oidc.empty')}</p>
  330. <Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="inline-flex items-center gap-2">
  331. <Plus className="w-4 h-4" />
  332. {t('settings.oidc.addProvider')}
  333. </Button>
  334. </div>
  335. </CardContent>
  336. </Card>
  337. )}
  338. {providers?.map((provider) => (
  339. <Card key={provider.id}>
  340. <CardHeader>
  341. <div className="flex items-center gap-3">
  342. <ProviderIconAvatar provider={provider} />
  343. <div className="flex-1">
  344. <div className="flex items-center gap-2">
  345. <h4 className="text-white font-medium">{provider.name}</h4>
  346. {provider.is_enabled ? (
  347. <span className="flex items-center gap-1 text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full">
  348. <Check className="w-3 h-3" /> {t('common.enabled')}
  349. </span>
  350. ) : (
  351. <span className="flex items-center gap-1 text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-0.5 rounded-full">
  352. <X className="w-3 h-3" /> {t('common.disabled')}
  353. </span>
  354. )}
  355. </div>
  356. <div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
  357. <ExternalLink className="w-3 h-3" />
  358. <span>{provider.issuer_url}</span>
  359. </div>
  360. </div>
  361. <div className="flex items-center gap-2">
  362. {provider.icon_url && (
  363. <Button
  364. variant="secondary"
  365. size="sm"
  366. onClick={() => refreshIconMutation.mutate(provider.id)}
  367. disabled={refreshIconMutation.isPending}
  368. title={t('settings.oidc.refreshIcon')}
  369. data-testid={`refresh-icon-${provider.id}`}
  370. >
  371. <RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
  372. </Button>
  373. )}
  374. {provider.has_icon && (
  375. <Button
  376. variant="secondary"
  377. size="sm"
  378. onClick={() => removeIconMutation.mutate(provider.id)}
  379. disabled={removeIconMutation.isPending}
  380. title={t('settings.oidc.removeIcon')}
  381. data-testid={`remove-icon-${provider.id}`}
  382. >
  383. <ImageOff className="w-4 h-4" />
  384. </Button>
  385. )}
  386. <Toggle
  387. checked={provider.is_enabled}
  388. onChange={() => toggleEnabled(provider)}
  389. disabled={updateMutation.isPending}
  390. />
  391. <Button
  392. variant="secondary"
  393. size="sm"
  394. onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
  395. >
  396. <Edit2 className="w-4 h-4" />
  397. </Button>
  398. <Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
  399. <Trash2 className="w-4 h-4" />
  400. </Button>
  401. </div>
  402. </div>
  403. </CardHeader>
  404. {editingId === provider.id && (
  405. <CardContent>
  406. <div className="border-t border-bambu-dark-tertiary pt-4">
  407. <ProviderForm
  408. isEdit={true}
  409. groups={groups}
  410. initial={{
  411. name: provider.name,
  412. issuer_url: provider.issuer_url,
  413. client_id: provider.client_id,
  414. client_secret: '',
  415. scopes: provider.scopes,
  416. is_enabled: provider.is_enabled,
  417. auto_create_users: provider.auto_create_users,
  418. auto_link_existing_accounts: provider.auto_link_existing_accounts,
  419. email_claim: provider.email_claim,
  420. require_email_verified: provider.require_email_verified,
  421. icon_url: provider.icon_url ?? undefined,
  422. default_group_id: provider.default_group_id ?? null,
  423. }}
  424. onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
  425. onCancel={() => setEditingId(null)}
  426. isPending={updateMutation.isPending}
  427. />
  428. </div>
  429. </CardContent>
  430. )}
  431. {editingId !== provider.id && (
  432. <CardContent>
  433. <dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
  434. <div>
  435. <dt className="text-bambu-gray">{t('settings.oidc.form.clientId')}</dt>
  436. <dd className="text-white font-mono truncate">{provider.client_id}</dd>
  437. </div>
  438. <div>
  439. <dt className="text-bambu-gray">{t('settings.oidc.form.scopes')}</dt>
  440. <dd className="text-white">{provider.scopes}</dd>
  441. </div>
  442. <div>
  443. <dt className="text-bambu-gray">{t('settings.oidc.form.autoCreate')}</dt>
  444. <dd className={provider.auto_create_users ? 'text-green-400' : 'text-bambu-gray'}>
  445. {provider.auto_create_users ? t('common.yes') : t('common.no')}
  446. </dd>
  447. </div>
  448. <div>
  449. <dt className="text-bambu-gray">{t('settings.oidc.form.autoLink')}</dt>
  450. <dd className={provider.auto_link_existing_accounts ? 'text-green-400' : 'text-bambu-gray'}>
  451. {provider.auto_link_existing_accounts ? t('common.yes') : t('common.no')}
  452. </dd>
  453. </div>
  454. <div>
  455. <dt className="text-bambu-gray">{t('settings.oidc.form.emailClaim')}</dt>
  456. <dd className="text-white font-mono">{provider.email_claim}</dd>
  457. </div>
  458. <div>
  459. <dt className="text-bambu-gray">{t('settings.oidc.form.requireEmailVerified')}</dt>
  460. <dd className={provider.require_email_verified ? 'text-green-400' : 'text-red-400'}>
  461. {provider.require_email_verified ? t('common.yes') : t('common.no')}
  462. </dd>
  463. </div>
  464. <div>
  465. <dt className="text-bambu-gray">{t('settings.oidc.form.defaultGroup')}</dt>
  466. <dd className="text-white">
  467. {provider.default_group_id
  468. ? (groups.find((g) => g.id === provider.default_group_id)?.name ?? t('settings.oidc.form.defaultGroupViewersFallback'))
  469. : t('settings.oidc.form.defaultGroupViewersFallback')}
  470. </dd>
  471. </div>
  472. </dl>
  473. </CardContent>
  474. )}
  475. </Card>
  476. ))}
  477. {/* Delete confirm */}
  478. {deleteTarget && (
  479. <ConfirmModal
  480. title={t('settings.oidc.deleteTitle')}
  481. message={t('settings.oidc.deleteMessage', { name: deleteTarget.name })}
  482. confirmText={t('common.delete')}
  483. variant="danger"
  484. onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
  485. onCancel={() => setDeleteTarget(null)}
  486. />
  487. )}
  488. </div>
  489. );
  490. }