OIDCProviderSettings.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  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, Lock } 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. group_claim: 'groups',
  24. group_mapping: {},
  25. icon_url: undefined,
  26. default_group_id: null,
  27. is_autologin: false,
  28. };
  29. // ─── Provider form (create / edit) ───────────────────────────────────────────
  30. function ProviderForm({
  31. initial,
  32. isEdit = false,
  33. groups = [],
  34. onSave,
  35. onCancel,
  36. isPending,
  37. }: {
  38. initial: OIDCProviderCreate;
  39. isEdit?: boolean;
  40. groups?: Group[];
  41. onSave: (data: OIDCProviderCreate) => void;
  42. onCancel: () => void;
  43. isPending: boolean;
  44. }) {
  45. const { t } = useTranslation();
  46. const [form, setForm] = useState<OIDCProviderCreate>(initial);
  47. const [secretChanged, setSecretChanged] = useState(false);
  48. // #3107 — each row is an { idpGroup -> bambuddyGroup } pair, edited directly
  49. // instead of as JSON. The Bambuddy side is a <select> sourced from `groups`,
  50. // so an invalid group name can't be entered in the first place.
  51. const [mappingRows, setMappingRows] = useState<{ idpGroup: string; bambuddyGroup: string }[]>(() =>
  52. Object.entries(initial.group_mapping ?? {}).map(([idpGroup, bambuddyGroup]) => ({ idpGroup, bambuddyGroup }))
  53. );
  54. const addMappingRow = () => setMappingRows((prev) => [...prev, { idpGroup: '', bambuddyGroup: '' }]);
  55. const removeMappingRow = (i: number) => setMappingRows((prev) => prev.filter((_, idx) => idx !== i));
  56. const updateMappingRow = (i: number, patch: Partial<{ idpGroup: string; bambuddyGroup: string }>) =>
  57. setMappingRows((prev) => prev.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
  58. const set = (key: keyof OIDCProviderCreate, value: unknown) =>
  59. setForm((prev) => ({ ...prev, [key]: value }));
  60. const inputCls =
  61. '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';
  62. const labelCls = 'block text-sm font-medium text-white mb-1';
  63. const handleSave = () => {
  64. const payload = { ...form };
  65. if (isEdit && !secretChanged) {
  66. delete (payload as Partial<OIDCProviderCreate>).client_secret;
  67. }
  68. // #3107 review: default the group claim on save, not on change —
  69. // snapping it back mid-edit means the field can only be typed over.
  70. if (!payload.group_claim || !payload.group_claim.trim()) {
  71. payload.group_claim = 'groups';
  72. }
  73. // #3107 — blank IdP-group name or unselected Bambuddy group means the row
  74. // isn't finished yet; drop it rather than saving a half-filled mapping.
  75. const mapping: Record<string, string> = {};
  76. for (const row of mappingRows) {
  77. const idpGroup = row.idpGroup.trim();
  78. if (idpGroup && row.bambuddyGroup) {
  79. mapping[idpGroup] = row.bambuddyGroup;
  80. }
  81. }
  82. payload.group_mapping = mapping;
  83. onSave(payload);
  84. };
  85. const groupNames = new Set(groups.map((g) => g.name));
  86. // A row with only one side filled in is unfinished, and a second row for an
  87. // IdP group already mapped above would be collapsed into one entry (the
  88. // backend compares IdP names ignoring case). Either way the saved mapping
  89. // would differ from what the form shows -- dropping the only row turns
  90. // group sync off -- so the row is flagged and Save stays disabled instead.
  91. // A row with both sides empty is a fresh "Add Mapping" and is just skipped.
  92. const mappingRowErrors = mappingRows.map((row, i) => {
  93. const idpGroup = row.idpGroup.trim();
  94. if (!idpGroup && !row.bambuddyGroup) return null;
  95. if (!idpGroup || !row.bambuddyGroup) return 'incomplete' as const;
  96. const key = idpGroup.toLowerCase();
  97. if (mappingRows.slice(0, i).some((prev) => prev.idpGroup.trim().toLowerCase() === key)) return 'duplicate' as const;
  98. return null;
  99. });
  100. const hasMappingErrors = mappingRowErrors.some((e) => e !== null);
  101. const autoLinkOn = form.auto_link_existing_accounts === true;
  102. const emailVerifiedOn = form.require_email_verified ?? true;
  103. let requireEmailVerifiedDesc: ReactNode;
  104. if (autoLinkOn) {
  105. requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedAutoLink');
  106. } else if (emailVerifiedOn) {
  107. requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedDesc');
  108. } else {
  109. requireEmailVerifiedDesc = (
  110. <span className="text-red-700 dark:text-red-400">{t('settings.oidc.form.requireEmailVerifiedWarning')}</span>
  111. );
  112. }
  113. return (
  114. <div className="space-y-4">
  115. <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
  116. <div>
  117. <label className={labelCls}>{t('settings.oidc.form.name')} <span className="text-red-700 dark:text-red-400">*</span></label>
  118. <input className={inputCls} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder="Google" />
  119. </div>
  120. <div>
  121. <label className={labelCls}>{t('settings.oidc.form.issuerUrl')} <span className="text-red-700 dark:text-red-400">*</span></label>
  122. <input className={inputCls} value={form.issuer_url} onChange={(e) => set('issuer_url', e.target.value)} placeholder="https://accounts.google.com" />
  123. </div>
  124. <div>
  125. <label className={labelCls}>{t('settings.oidc.form.clientId')} <span className="text-red-700 dark:text-red-400">*</span></label>
  126. <input className={inputCls} value={form.client_id} onChange={(e) => set('client_id', e.target.value)} placeholder="your-client-id" />
  127. </div>
  128. <div>
  129. <label className={labelCls}>
  130. {t('settings.oidc.form.clientSecret')}
  131. {!isEdit && <span className="text-red-700 dark:text-red-400"> *</span>}
  132. {isEdit && <span className="text-bambu-gray text-xs ml-1">({t('settings.oidc.form.secretHint')})</span>}
  133. </label>
  134. <input
  135. className={inputCls}
  136. type="password"
  137. value={secretChanged ? form.client_secret : ''}
  138. placeholder={isEdit && !secretChanged ? '••••••••' : t('settings.oidc.form.secretPlaceholder')}
  139. onChange={(e) => {
  140. setSecretChanged(true);
  141. set('client_secret', e.target.value);
  142. }}
  143. />
  144. </div>
  145. <div>
  146. <label className={labelCls}>{t('settings.oidc.form.scopes')}</label>
  147. <input className={inputCls} value={form.scopes} onChange={(e) => set('scopes', e.target.value)} placeholder="openid email profile" />
  148. </div>
  149. <div>
  150. <label className={labelCls}>{t('settings.oidc.form.iconUrl')}</label>
  151. <input
  152. className={inputCls}
  153. value={form.icon_url ?? ''}
  154. onChange={(e) => set('icon_url', e.target.value === '' ? null : e.target.value)}
  155. placeholder="https://..."
  156. />
  157. </div>
  158. </div>
  159. <div className="flex flex-wrap gap-6 pt-2">
  160. <label className="flex items-center gap-3 cursor-pointer">
  161. <Toggle checked={form.is_enabled ?? true} onChange={(v) => set('is_enabled', v)} />
  162. <span className="text-white text-sm">{t('settings.oidc.form.enabled')}</span>
  163. </label>
  164. <label className="flex items-center gap-3 cursor-pointer">
  165. <Toggle checked={form.auto_create_users ?? false} onChange={(v) => set('auto_create_users', v)} />
  166. <div>
  167. <p className="text-white text-sm">{t('settings.oidc.form.autoCreate')}</p>
  168. <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoCreateDesc')}</p>
  169. </div>
  170. </label>
  171. <label className="flex items-center gap-3 cursor-pointer w-full">
  172. <Toggle checked={form.auto_link_existing_accounts ?? false} onChange={(v) => set('auto_link_existing_accounts', v)} />
  173. <div>
  174. <p className="text-white text-sm">{t('settings.oidc.form.autoLink')}</p>
  175. <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoLinkDesc')}</p>
  176. </div>
  177. </label>
  178. <label className="flex items-center gap-3 cursor-pointer w-full">
  179. <Toggle
  180. checked={emailVerifiedOn}
  181. onChange={(v) => set('require_email_verified', v)}
  182. disabled={autoLinkOn}
  183. />
  184. <div>
  185. <p className="text-white text-sm">{t('settings.oidc.form.requireEmailVerified')}</p>
  186. <p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
  187. </div>
  188. </label>
  189. <label className="flex items-center gap-3 cursor-pointer w-full">
  190. <Toggle checked={form.is_autologin ?? false} onChange={(v) => set('is_autologin', v)} />
  191. <div>
  192. <p className="text-white text-sm">{t('settings.oidc.form.autologin')}</p>
  193. <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autologinDesc')}</p>
  194. </div>
  195. </label>
  196. </div>
  197. <div>
  198. <label className={labelCls}>{t('settings.oidc.form.emailClaim')}</label>
  199. <input
  200. className={inputCls}
  201. value={form.email_claim}
  202. onChange={(e) => set('email_claim', e.target.value || 'email')}
  203. placeholder={t('settings.oidc.form.emailClaimPlaceholder')}
  204. />
  205. <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.emailClaimDesc')}</p>
  206. {autoLinkOn && form.email_claim !== 'email' && (
  207. <p className="text-yellow-700 dark:text-yellow-400 text-xs mt-1">{t('settings.oidc.form.emailClaimCustomClaimAutoLinkWarning')}</p>
  208. )}
  209. </div>
  210. <div>
  211. <label className={labelCls}>{t('settings.oidc.form.defaultGroup')}</label>
  212. <select
  213. className={inputCls}
  214. value={form.default_group_id ?? ''}
  215. onChange={(e) => set('default_group_id', e.target.value ? Number(e.target.value) : null)}
  216. >
  217. <option value="">{t('settings.oidc.form.defaultGroupViewersFallback')}</option>
  218. {groups.map((g) => (
  219. <option key={g.id} value={g.id}>{g.name}</option>
  220. ))}
  221. </select>
  222. <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.defaultGroupDesc')}</p>
  223. </div>
  224. <div>
  225. <label className={labelCls}>{t('settings.oidc.form.groupClaim')}</label>
  226. <input
  227. className={inputCls}
  228. value={form.group_claim ?? 'groups'}
  229. onChange={(e) => set('group_claim', e.target.value)}
  230. placeholder="groups"
  231. />
  232. <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.groupClaimDesc')}</p>
  233. </div>
  234. <div>
  235. <label className={labelCls}>{t('settings.oidc.form.groupMapping')}</label>
  236. <div className="space-y-2">
  237. {mappingRows.map((row, i) => {
  238. // A row can point at a group name that's since been deleted
  239. // (deleting a group doesn't touch any provider's mapping — see
  240. // #3107 follow-up). Left alone, that value doesn't match any
  241. // <option> and the select just renders as if nothing were
  242. // chosen, so the broken row looks identical to an unset one.
  243. // Injecting the stale name as its own (disabled) option keeps it
  244. // visibly selected, and the row is flagged red until the admin
  245. // repoints it or removes it.
  246. const isOrphaned = row.bambuddyGroup !== '' && !groupNames.has(row.bambuddyGroup);
  247. return (
  248. <div key={i}>
  249. <div className="flex items-center gap-2">
  250. <input
  251. className={inputCls}
  252. value={row.idpGroup}
  253. onChange={(e) => updateMappingRow(i, { idpGroup: e.target.value })}
  254. placeholder={t('settings.oidc.form.groupMappingIdpGroupPlaceholder')}
  255. />
  256. <span className="text-bambu-gray text-sm shrink-0">&rarr;</span>
  257. <select
  258. className={`${inputCls} ${isOrphaned ? 'border-red-700 dark:border-red-400 text-red-700 dark:text-red-400' : ''}`}
  259. value={row.bambuddyGroup}
  260. onChange={(e) => updateMappingRow(i, { bambuddyGroup: e.target.value })}
  261. >
  262. <option value="">{t('settings.oidc.form.groupMappingSelectGroup')}</option>
  263. {isOrphaned && (
  264. <option value={row.bambuddyGroup}>
  265. {t('settings.oidc.form.groupMappingDeletedGroupOption', { group: row.bambuddyGroup })}
  266. </option>
  267. )}
  268. {groups.map((g) => (
  269. <option key={g.id} value={g.name}>{g.name}</option>
  270. ))}
  271. </select>
  272. <Button variant="secondary" size="sm" onClick={() => removeMappingRow(i)} title={t('common.remove')}>
  273. <Trash2 className="w-4 h-4" />
  274. </Button>
  275. </div>
  276. {isOrphaned && (
  277. <p className="text-red-700 dark:text-red-400 text-xs mt-1">
  278. {t('settings.oidc.form.groupMappingDeletedGroupWarning')}
  279. </p>
  280. )}
  281. {mappingRowErrors[i] && (
  282. <p className="text-red-700 dark:text-red-400 text-xs mt-1">
  283. {mappingRowErrors[i] === 'incomplete'
  284. ? t('settings.oidc.form.groupMappingIncompleteRow')
  285. : t('settings.oidc.form.groupMappingDuplicateRow')}
  286. </p>
  287. )}
  288. </div>
  289. );
  290. })}
  291. <Button variant="secondary" size="sm" onClick={addMappingRow} className="inline-flex items-center gap-2">
  292. <Plus className="w-4 h-4" />
  293. {t('settings.oidc.form.groupMappingAddRow')}
  294. </Button>
  295. </div>
  296. <p className="text-bambu-gray text-xs mt-1">{t('settings.oidc.form.groupMappingDesc')}</p>
  297. </div>
  298. <div className="flex gap-3 pt-2">
  299. <Button variant="secondary" onClick={onCancel} className="flex-1">
  300. {t('common.cancel')}
  301. </Button>
  302. <Button
  303. variant="primary"
  304. className="flex-1"
  305. disabled={!form.name || !form.issuer_url || !form.client_id || (!isEdit && !form.client_secret) || (isEdit && secretChanged && !form.client_secret) || hasMappingErrors || isPending}
  306. onClick={handleSave}
  307. >
  308. {isPending ? t('common.saving') : t('common.save')}
  309. </Button>
  310. </div>
  311. </div>
  312. );
  313. }
  314. /**
  315. * Per-provider icon avatar in the admin Settings list (#1333 review).
  316. *
  317. * Extracted so each card has its own `iconFailed` state. Previously
  318. * `onError` just set `display: none` and the admin saw an unexplained gap
  319. * where the icon should be — now we swap in the Globe fallback exactly
  320. * like the `has_icon === false` branch, so the visual state is
  321. * self-explanatory regardless of why the icon didn't load.
  322. */
  323. function ProviderIconAvatar({ provider }: { provider: OIDCProvider }) {
  324. const [iconFailed, setIconFailed] = useState(false);
  325. const showIcon = provider.has_icon && !iconFailed;
  326. if (showIcon) {
  327. return (
  328. <img
  329. src={api.oidcProviderIconUrl(provider.id)}
  330. alt={provider.name}
  331. className="w-8 h-8 rounded object-contain"
  332. onError={() => setIconFailed(true)}
  333. />
  334. );
  335. }
  336. return (
  337. <div className="w-8 h-8 rounded-full bg-bambu-dark-tertiary flex items-center justify-center">
  338. <Globe className="w-4 h-4 text-bambu-gray" />
  339. </div>
  340. );
  341. }
  342. // ─── Main component ───────────────────────────────────────────────────────────
  343. export function OIDCProviderSettings() {
  344. const { t } = useTranslation();
  345. const queryClient = useQueryClient();
  346. const { showToast } = useToast();
  347. const [showCreate, setShowCreate] = useState(false);
  348. const [editingId, setEditingId] = useState<number | null>(null);
  349. const [deleteTarget, setDeleteTarget] = useState<OIDCProvider | null>(null);
  350. const { data: providers, isLoading } = useQuery({
  351. queryKey: ['oidc-providers-all'],
  352. queryFn: () => api.getOIDCProvidersAll(),
  353. });
  354. const { data: groups = [] } = useQuery({
  355. queryKey: ['groups'],
  356. queryFn: () => api.getGroups(),
  357. });
  358. const createMutation = useMutation({
  359. mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),
  360. onSuccess: () => {
  361. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  362. setShowCreate(false);
  363. showToast(t('settings.oidc.created'), 'success');
  364. },
  365. onError: (e: Error) => showToast(e.message, 'error'),
  366. });
  367. const updateMutation = useMutation({
  368. mutationFn: ({ id, data }: { id: number; data: Partial<OIDCProviderCreate> }) =>
  369. api.updateOIDCProvider(id, data),
  370. onSuccess: () => {
  371. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  372. setEditingId(null);
  373. showToast(t('settings.oidc.updated'), 'success');
  374. },
  375. onError: (e: Error) => showToast(e.message, 'error'),
  376. });
  377. const deleteMutation = useMutation({
  378. mutationFn: (id: number) => api.deleteOIDCProvider(id),
  379. onSuccess: () => {
  380. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  381. setDeleteTarget(null);
  382. showToast(t('settings.oidc.deleted'), 'success');
  383. },
  384. onError: (e: Error) => showToast(e.message, 'error'),
  385. });
  386. // Icon-proxy mutations (#1333). Refresh re-fetches from the stored
  387. // icon_url; remove clears the cached bytes but keeps icon_url.
  388. const refreshIconMutation = useMutation({
  389. mutationFn: (id: number) => api.refreshOIDCProviderIcon(id),
  390. onSuccess: () => {
  391. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  392. queryClient.invalidateQueries({ queryKey: ['oidc-providers'] });
  393. showToast(t('settings.oidc.iconRefreshed'), 'success');
  394. },
  395. onError: (e: Error) => showToast(e.message || t('settings.oidc.iconFetchFailed'), 'error'),
  396. });
  397. const removeIconMutation = useMutation({
  398. mutationFn: (id: number) => api.deleteOIDCProviderIcon(id),
  399. onSuccess: () => {
  400. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  401. queryClient.invalidateQueries({ queryKey: ['oidc-providers'] });
  402. showToast(t('settings.oidc.iconRemoved'), 'success');
  403. },
  404. onError: (e: Error) => showToast(e.message, 'error'),
  405. });
  406. const toggleEnabled = (provider: OIDCProvider) =>
  407. updateMutation.mutate({ id: provider.id, data: { is_enabled: !provider.is_enabled } });
  408. if (isLoading) {
  409. return (
  410. <div className="flex items-center justify-center py-12">
  411. <RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
  412. </div>
  413. );
  414. }
  415. return (
  416. <div className="space-y-6">
  417. {/* Header */}
  418. <Card id="card-oidc">
  419. <CardHeader>
  420. <div className="flex items-center justify-between">
  421. <div>
  422. <h3 className="text-white font-semibold">{t('settings.oidc.title')}</h3>
  423. <p className="text-bambu-gray text-sm">{t('settings.oidc.desc')}</p>
  424. </div>
  425. {!showCreate && (
  426. <Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="flex items-center gap-2">
  427. <Plus className="w-4 h-4" />
  428. {t('settings.oidc.addProvider')}
  429. </Button>
  430. )}
  431. </div>
  432. </CardHeader>
  433. {showCreate && (
  434. <CardContent>
  435. <div className="border-t border-bambu-dark-tertiary pt-4">
  436. <h4 className="text-white font-medium mb-4">{t('settings.oidc.newProvider')}</h4>
  437. <ProviderForm
  438. initial={EMPTY_FORM}
  439. groups={groups}
  440. onSave={(data) => createMutation.mutate(data)}
  441. onCancel={() => setShowCreate(false)}
  442. isPending={createMutation.isPending}
  443. />
  444. </div>
  445. </CardContent>
  446. )}
  447. </Card>
  448. {/* Provider list */}
  449. {providers && providers.length === 0 && !showCreate && (
  450. <Card id="card-oidc-empty">
  451. <CardContent>
  452. <div className="text-center py-8 space-y-3">
  453. <Globe className="w-12 h-12 text-bambu-gray mx-auto" />
  454. <p className="text-bambu-gray">{t('settings.oidc.empty')}</p>
  455. <Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="inline-flex items-center gap-2">
  456. <Plus className="w-4 h-4" />
  457. {t('settings.oidc.addProvider')}
  458. </Button>
  459. </div>
  460. </CardContent>
  461. </Card>
  462. )}
  463. {providers?.map((provider) => (
  464. <Card key={provider.id}>
  465. <CardHeader>
  466. <div className="flex items-center gap-3">
  467. <ProviderIconAvatar provider={provider} />
  468. <div className="flex-1">
  469. <div className="flex items-center gap-2">
  470. <h4 className="text-white font-medium">{provider.name}</h4>
  471. {provider.is_enabled ? (
  472. <span className="flex items-center gap-1 text-xs text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-400/10 px-2 py-0.5 rounded-full">
  473. <Check className="w-3 h-3" /> {t('common.enabled')}
  474. </span>
  475. ) : (
  476. <span className="flex items-center gap-1 text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-0.5 rounded-full">
  477. <X className="w-3 h-3" /> {t('common.disabled')}
  478. </span>
  479. )}
  480. {provider.is_env_managed && (
  481. <span className="flex items-center gap-1 text-xs text-bambu-green">
  482. <Lock className="w-3 h-3" /> {t('settings.environmentManagedLabel')}
  483. </span>
  484. )}
  485. </div>
  486. <div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
  487. <ExternalLink className="w-3 h-3" />
  488. <span>{provider.issuer_url}</span>
  489. </div>
  490. </div>
  491. <div className="flex items-center gap-2">
  492. {/* #2593: startup rewrites the env-managed row from BAMBUDDY_OIDC_*
  493. and the API answers 409, so offering any of these would promise
  494. a change that cannot land -- the icon routes included, where the
  495. click only ever produced an error toast. */}
  496. {!provider.is_env_managed && (
  497. <>
  498. {provider.icon_url && (
  499. <Button
  500. variant="secondary"
  501. size="sm"
  502. onClick={() => refreshIconMutation.mutate(provider.id)}
  503. disabled={refreshIconMutation.isPending}
  504. title={t('settings.oidc.refreshIcon')}
  505. data-testid={`refresh-icon-${provider.id}`}
  506. >
  507. <RefreshCw className={`w-4 h-4 ${refreshIconMutation.isPending ? 'animate-spin' : ''}`} />
  508. </Button>
  509. )}
  510. {provider.has_icon && (
  511. <Button
  512. variant="secondary"
  513. size="sm"
  514. onClick={() => removeIconMutation.mutate(provider.id)}
  515. disabled={removeIconMutation.isPending}
  516. title={t('settings.oidc.removeIcon')}
  517. data-testid={`remove-icon-${provider.id}`}
  518. >
  519. <ImageOff className="w-4 h-4" />
  520. </Button>
  521. )}
  522. <Toggle
  523. checked={provider.is_enabled}
  524. onChange={() => toggleEnabled(provider)}
  525. disabled={updateMutation.isPending}
  526. />
  527. <Button
  528. variant="secondary"
  529. size="sm"
  530. onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
  531. data-testid={`edit-provider-${provider.id}`}
  532. >
  533. <Edit2 className="w-4 h-4" />
  534. </Button>
  535. <Button
  536. variant="danger"
  537. size="sm"
  538. onClick={() => setDeleteTarget(provider)}
  539. data-testid={`delete-provider-${provider.id}`}
  540. >
  541. <Trash2 className="w-4 h-4" />
  542. </Button>
  543. </>
  544. )}
  545. </div>
  546. </div>
  547. </CardHeader>
  548. {editingId === provider.id && (
  549. <CardContent>
  550. <div className="border-t border-bambu-dark-tertiary pt-4">
  551. <ProviderForm
  552. isEdit={true}
  553. groups={groups}
  554. initial={{
  555. name: provider.name,
  556. issuer_url: provider.issuer_url,
  557. client_id: provider.client_id,
  558. client_secret: '',
  559. scopes: provider.scopes,
  560. is_enabled: provider.is_enabled,
  561. auto_create_users: provider.auto_create_users,
  562. auto_link_existing_accounts: provider.auto_link_existing_accounts,
  563. email_claim: provider.email_claim,
  564. require_email_verified: provider.require_email_verified,
  565. group_claim: provider.group_claim ?? 'groups',
  566. group_mapping: provider.group_mapping ?? {},
  567. icon_url: provider.icon_url ?? undefined,
  568. default_group_id: provider.default_group_id ?? null,
  569. is_autologin: provider.is_autologin,
  570. }}
  571. onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
  572. onCancel={() => setEditingId(null)}
  573. isPending={updateMutation.isPending}
  574. />
  575. </div>
  576. </CardContent>
  577. )}
  578. {editingId !== provider.id && (
  579. <CardContent>
  580. <dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
  581. <div>
  582. <dt className="text-bambu-gray">{t('settings.oidc.form.clientId')}</dt>
  583. <dd className="text-white font-mono truncate">{provider.client_id}</dd>
  584. </div>
  585. <div>
  586. <dt className="text-bambu-gray">{t('settings.oidc.form.scopes')}</dt>
  587. <dd className="text-white">{provider.scopes}</dd>
  588. </div>
  589. <div>
  590. <dt className="text-bambu-gray">{t('settings.oidc.form.autoCreate')}</dt>
  591. <dd className={provider.auto_create_users ? 'text-green-700 dark:text-green-400' : 'text-bambu-gray'}>
  592. {provider.auto_create_users ? t('common.yes') : t('common.no')}
  593. </dd>
  594. </div>
  595. <div>
  596. <dt className="text-bambu-gray">{t('settings.oidc.form.autoLink')}</dt>
  597. <dd className={provider.auto_link_existing_accounts ? 'text-green-700 dark:text-green-400' : 'text-bambu-gray'}>
  598. {provider.auto_link_existing_accounts ? t('common.yes') : t('common.no')}
  599. </dd>
  600. </div>
  601. <div>
  602. <dt className="text-bambu-gray">{t('settings.oidc.form.emailClaim')}</dt>
  603. <dd className="text-white font-mono">{provider.email_claim}</dd>
  604. </div>
  605. <div>
  606. <dt className="text-bambu-gray">{t('settings.oidc.form.groupClaim')}</dt>
  607. <dd className="text-white font-mono">{provider.group_claim ?? 'groups'}</dd>
  608. </div>
  609. <div>
  610. <dt className="text-bambu-gray">{t('settings.oidc.form.requireEmailVerified')}</dt>
  611. <dd className={provider.require_email_verified ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}>
  612. {provider.require_email_verified ? t('common.yes') : t('common.no')}
  613. </dd>
  614. </div>
  615. <div>
  616. <dt className="text-bambu-gray">{t('settings.oidc.form.defaultGroup')}</dt>
  617. <dd className="text-white">
  618. {provider.default_group_id
  619. ? (groups.find((g) => g.id === provider.default_group_id)?.name ?? t('settings.oidc.form.defaultGroupViewersFallback'))
  620. : t('settings.oidc.form.defaultGroupViewersFallback')}
  621. </dd>
  622. </div>
  623. <div>
  624. <dt className="text-bambu-gray">{t('settings.oidc.form.groupSync')}</dt>
  625. <dd className={provider.group_mapping && Object.keys(provider.group_mapping).length > 0 ? 'text-green-700 dark:text-green-400' : 'text-bambu-gray'}>
  626. {provider.group_mapping && Object.keys(provider.group_mapping).length > 0
  627. ? t('settings.oidc.form.groupSyncOn')
  628. : t('common.off')}
  629. </dd>
  630. </div>
  631. </dl>
  632. </CardContent>
  633. )}
  634. </Card>
  635. ))}
  636. {/* Delete confirm */}
  637. {deleteTarget && (
  638. <ConfirmModal
  639. title={t('settings.oidc.deleteTitle')}
  640. message={t('settings.oidc.deleteMessage', { name: deleteTarget.name })}
  641. confirmText={t('common.delete')}
  642. variant="danger"
  643. onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
  644. onCancel={() => setDeleteTarget(null)}
  645. />
  646. )}
  647. </div>
  648. );
  649. }