OIDCProviderSettings.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. import { useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { api } from '../api/client';
  6. import type { 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. icon_url: undefined,
  22. };
  23. // ─── Provider form (create / edit) ───────────────────────────────────────────
  24. function ProviderForm({
  25. initial,
  26. isEdit = false,
  27. onSave,
  28. onCancel,
  29. isPending,
  30. }: {
  31. initial: OIDCProviderCreate;
  32. isEdit?: boolean;
  33. onSave: (data: OIDCProviderCreate) => void;
  34. onCancel: () => void;
  35. isPending: boolean;
  36. }) {
  37. const { t } = useTranslation();
  38. const [form, setForm] = useState<OIDCProviderCreate>(initial);
  39. const [secretChanged, setSecretChanged] = useState(false);
  40. const set = (key: keyof OIDCProviderCreate, value: unknown) =>
  41. setForm((prev) => ({ ...prev, [key]: value }));
  42. const inputCls =
  43. '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';
  44. const labelCls = 'block text-sm font-medium text-white mb-1';
  45. const handleSave = () => {
  46. const payload = { ...form };
  47. if (isEdit && !secretChanged) {
  48. delete (payload as Partial<OIDCProviderCreate>).client_secret;
  49. }
  50. onSave(payload);
  51. };
  52. return (
  53. <div className="space-y-4">
  54. <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
  55. <div>
  56. <label className={labelCls}>{t('settings.oidc.form.name')} <span className="text-red-400">*</span></label>
  57. <input className={inputCls} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder="Google" />
  58. </div>
  59. <div>
  60. <label className={labelCls}>{t('settings.oidc.form.issuerUrl')} <span className="text-red-400">*</span></label>
  61. <input className={inputCls} value={form.issuer_url} onChange={(e) => set('issuer_url', e.target.value)} placeholder="https://accounts.google.com" />
  62. </div>
  63. <div>
  64. <label className={labelCls}>{t('settings.oidc.form.clientId')} <span className="text-red-400">*</span></label>
  65. <input className={inputCls} value={form.client_id} onChange={(e) => set('client_id', e.target.value)} placeholder="your-client-id" />
  66. </div>
  67. <div>
  68. <label className={labelCls}>
  69. {t('settings.oidc.form.clientSecret')}
  70. {!isEdit && <span className="text-red-400"> *</span>}
  71. {isEdit && <span className="text-bambu-gray text-xs ml-1">({t('settings.oidc.form.secretHint')})</span>}
  72. </label>
  73. <input
  74. className={inputCls}
  75. type="password"
  76. value={secretChanged ? form.client_secret : ''}
  77. placeholder={isEdit && !secretChanged ? '••••••••' : t('settings.oidc.form.secretPlaceholder')}
  78. onChange={(e) => {
  79. setSecretChanged(true);
  80. set('client_secret', e.target.value);
  81. }}
  82. />
  83. </div>
  84. <div>
  85. <label className={labelCls}>{t('settings.oidc.form.scopes')}</label>
  86. <input className={inputCls} value={form.scopes} onChange={(e) => set('scopes', e.target.value)} placeholder="openid email profile" />
  87. </div>
  88. <div>
  89. <label className={labelCls}>{t('settings.oidc.form.iconUrl')}</label>
  90. <input className={inputCls} value={form.icon_url ?? ''} onChange={(e) => set('icon_url', e.target.value || undefined)} placeholder="https://..." />
  91. </div>
  92. </div>
  93. <div className="flex flex-wrap gap-6 pt-2">
  94. <label className="flex items-center gap-3 cursor-pointer">
  95. <Toggle checked={form.is_enabled ?? true} onChange={(v) => set('is_enabled', v)} />
  96. <span className="text-white text-sm">{t('settings.oidc.form.enabled')}</span>
  97. </label>
  98. <label className="flex items-center gap-3 cursor-pointer">
  99. <Toggle checked={form.auto_create_users ?? false} onChange={(v) => set('auto_create_users', v)} />
  100. <div>
  101. <p className="text-white text-sm">{t('settings.oidc.form.autoCreate')}</p>
  102. <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoCreateDesc')}</p>
  103. </div>
  104. </label>
  105. <label className="flex items-center gap-3 cursor-pointer">
  106. <Toggle checked={form.auto_link_existing_accounts ?? false} onChange={(v) => set('auto_link_existing_accounts', v)} />
  107. <div>
  108. <p className="text-white text-sm">{t('settings.oidc.form.autoLink')}</p>
  109. <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autoLinkDesc')}</p>
  110. </div>
  111. </label>
  112. </div>
  113. <div className="flex gap-3 pt-2">
  114. <Button variant="secondary" onClick={onCancel} className="flex-1">
  115. {t('common.cancel')}
  116. </Button>
  117. <Button
  118. variant="primary"
  119. className="flex-1"
  120. disabled={!form.name || !form.issuer_url || !form.client_id || (!isEdit && !form.client_secret) || (isEdit && secretChanged && !form.client_secret) || isPending}
  121. onClick={handleSave}
  122. >
  123. {isPending ? t('common.saving') : t('common.save')}
  124. </Button>
  125. </div>
  126. </div>
  127. );
  128. }
  129. // ─── Main component ───────────────────────────────────────────────────────────
  130. export function OIDCProviderSettings() {
  131. const { t } = useTranslation();
  132. const queryClient = useQueryClient();
  133. const { showToast } = useToast();
  134. const [showCreate, setShowCreate] = useState(false);
  135. const [editingId, setEditingId] = useState<number | null>(null);
  136. const [deleteTarget, setDeleteTarget] = useState<OIDCProvider | null>(null);
  137. const { data: providers, isLoading } = useQuery({
  138. queryKey: ['oidc-providers-all'],
  139. queryFn: () => api.getOIDCProvidersAll(),
  140. });
  141. const createMutation = useMutation({
  142. mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data),
  143. onSuccess: () => {
  144. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  145. setShowCreate(false);
  146. showToast(t('settings.oidc.created'), 'success');
  147. },
  148. onError: (e: Error) => showToast(e.message, 'error'),
  149. });
  150. const updateMutation = useMutation({
  151. mutationFn: ({ id, data }: { id: number; data: Partial<OIDCProviderCreate> }) =>
  152. api.updateOIDCProvider(id, data),
  153. onSuccess: () => {
  154. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  155. setEditingId(null);
  156. showToast(t('settings.oidc.updated'), 'success');
  157. },
  158. onError: (e: Error) => showToast(e.message, 'error'),
  159. });
  160. const deleteMutation = useMutation({
  161. mutationFn: (id: number) => api.deleteOIDCProvider(id),
  162. onSuccess: () => {
  163. queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] });
  164. setDeleteTarget(null);
  165. showToast(t('settings.oidc.deleted'), 'success');
  166. },
  167. onError: (e: Error) => showToast(e.message, 'error'),
  168. });
  169. const toggleEnabled = (provider: OIDCProvider) =>
  170. updateMutation.mutate({ id: provider.id, data: { is_enabled: !provider.is_enabled } });
  171. if (isLoading) {
  172. return (
  173. <div className="flex items-center justify-center py-12">
  174. <RefreshCw className="w-6 h-6 animate-spin text-bambu-green" />
  175. </div>
  176. );
  177. }
  178. return (
  179. <div className="space-y-6">
  180. {/* Header */}
  181. <Card id="card-oidc">
  182. <CardHeader>
  183. <div className="flex items-center justify-between">
  184. <div>
  185. <h3 className="text-white font-semibold">{t('settings.oidc.title')}</h3>
  186. <p className="text-bambu-gray text-sm">{t('settings.oidc.desc')}</p>
  187. </div>
  188. {!showCreate && (
  189. <Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="flex items-center gap-2">
  190. <Plus className="w-4 h-4" />
  191. {t('settings.oidc.addProvider')}
  192. </Button>
  193. )}
  194. </div>
  195. </CardHeader>
  196. {showCreate && (
  197. <CardContent>
  198. <div className="border-t border-bambu-dark-tertiary pt-4">
  199. <h4 className="text-white font-medium mb-4">{t('settings.oidc.newProvider')}</h4>
  200. <ProviderForm
  201. initial={EMPTY_FORM}
  202. onSave={(data) => createMutation.mutate(data)}
  203. onCancel={() => setShowCreate(false)}
  204. isPending={createMutation.isPending}
  205. />
  206. </div>
  207. </CardContent>
  208. )}
  209. </Card>
  210. {/* Provider list */}
  211. {providers && providers.length === 0 && !showCreate && (
  212. <Card id="card-oidc-empty">
  213. <CardContent>
  214. <div className="text-center py-8 space-y-3">
  215. <Globe className="w-12 h-12 text-bambu-gray mx-auto" />
  216. <p className="text-bambu-gray">{t('settings.oidc.empty')}</p>
  217. <Button variant="primary" size="sm" onClick={() => setShowCreate(true)} className="inline-flex items-center gap-2">
  218. <Plus className="w-4 h-4" />
  219. {t('settings.oidc.addProvider')}
  220. </Button>
  221. </div>
  222. </CardContent>
  223. </Card>
  224. )}
  225. {providers?.map((provider) => (
  226. <Card key={provider.id}>
  227. <CardHeader>
  228. <div className="flex items-center gap-3">
  229. {provider.icon_url ? (
  230. <img src={provider.icon_url} alt={provider.name} className="w-8 h-8 rounded object-contain" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
  231. ) : (
  232. <div className="w-8 h-8 rounded-full bg-bambu-dark-tertiary flex items-center justify-center">
  233. <Globe className="w-4 h-4 text-bambu-gray" />
  234. </div>
  235. )}
  236. <div className="flex-1">
  237. <div className="flex items-center gap-2">
  238. <h4 className="text-white font-medium">{provider.name}</h4>
  239. {provider.is_enabled ? (
  240. <span className="flex items-center gap-1 text-xs text-green-400 bg-green-400/10 px-2 py-0.5 rounded-full">
  241. <Check className="w-3 h-3" /> {t('common.enabled')}
  242. </span>
  243. ) : (
  244. <span className="flex items-center gap-1 text-xs text-bambu-gray bg-bambu-dark-tertiary px-2 py-0.5 rounded-full">
  245. <X className="w-3 h-3" /> {t('common.disabled')}
  246. </span>
  247. )}
  248. </div>
  249. <div className="flex items-center gap-1 text-bambu-gray text-xs mt-0.5">
  250. <ExternalLink className="w-3 h-3" />
  251. <span>{provider.issuer_url}</span>
  252. </div>
  253. </div>
  254. <div className="flex items-center gap-2">
  255. <Toggle
  256. checked={provider.is_enabled}
  257. onChange={() => toggleEnabled(provider)}
  258. disabled={updateMutation.isPending}
  259. />
  260. <Button
  261. variant="secondary"
  262. size="sm"
  263. onClick={() => setEditingId(editingId === provider.id ? null : provider.id)}
  264. >
  265. <Edit2 className="w-4 h-4" />
  266. </Button>
  267. <Button variant="danger" size="sm" onClick={() => setDeleteTarget(provider)}>
  268. <Trash2 className="w-4 h-4" />
  269. </Button>
  270. </div>
  271. </div>
  272. </CardHeader>
  273. {editingId === provider.id && (
  274. <CardContent>
  275. <div className="border-t border-bambu-dark-tertiary pt-4">
  276. <ProviderForm
  277. isEdit={true}
  278. initial={{
  279. name: provider.name,
  280. issuer_url: provider.issuer_url,
  281. client_id: provider.client_id,
  282. client_secret: '',
  283. scopes: provider.scopes,
  284. is_enabled: provider.is_enabled,
  285. auto_create_users: provider.auto_create_users,
  286. auto_link_existing_accounts: provider.auto_link_existing_accounts,
  287. icon_url: provider.icon_url ?? undefined,
  288. }}
  289. onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
  290. onCancel={() => setEditingId(null)}
  291. isPending={updateMutation.isPending}
  292. />
  293. </div>
  294. </CardContent>
  295. )}
  296. {editingId !== provider.id && (
  297. <CardContent>
  298. <dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
  299. <div>
  300. <dt className="text-bambu-gray">{t('settings.oidc.form.clientId')}</dt>
  301. <dd className="text-white font-mono truncate">{provider.client_id}</dd>
  302. </div>
  303. <div>
  304. <dt className="text-bambu-gray">{t('settings.oidc.form.scopes')}</dt>
  305. <dd className="text-white">{provider.scopes}</dd>
  306. </div>
  307. <div>
  308. <dt className="text-bambu-gray">{t('settings.oidc.form.autoCreate')}</dt>
  309. <dd className={provider.auto_create_users ? 'text-green-400' : 'text-bambu-gray'}>
  310. {provider.auto_create_users ? t('common.yes') : t('common.no')}
  311. </dd>
  312. </div>
  313. <div>
  314. <dt className="text-bambu-gray">{t('settings.oidc.form.autoLink')}</dt>
  315. <dd className={provider.auto_link_existing_accounts ? 'text-green-400' : 'text-bambu-gray'}>
  316. {provider.auto_link_existing_accounts ? t('common.yes') : t('common.no')}
  317. </dd>
  318. </div>
  319. </dl>
  320. </CardContent>
  321. )}
  322. </Card>
  323. ))}
  324. {/* Delete confirm */}
  325. {deleteTarget && (
  326. <ConfirmModal
  327. title={t('settings.oidc.deleteTitle')}
  328. message={t('settings.oidc.deleteMessage', { name: deleteTarget.name })}
  329. confirmText={t('common.delete')}
  330. variant="danger"
  331. onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
  332. onCancel={() => setDeleteTarget(null)}
  333. />
  334. )}
  335. </div>
  336. );
  337. }