OIDCProviderSettings.tsx 14 KB

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