OIDCProviderSettings.tsx 22 KB

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