import { useState, type ReactNode } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Edit2, Trash2, Globe, Check, X, RefreshCw, ExternalLink, ImageOff } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { api } from '../api/client'; import type { Group, OIDCProvider, OIDCProviderCreate } from '../api/client'; import { Card, CardContent, CardHeader } from './Card'; import { Button } from './Button'; import { Toggle } from './Toggle'; import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; const EMPTY_FORM: OIDCProviderCreate = { name: '', issuer_url: '', client_id: '', client_secret: '', scopes: 'openid email profile', is_enabled: true, auto_create_users: false, auto_link_existing_accounts: false, email_claim: 'email', require_email_verified: true, icon_url: undefined, default_group_id: null, }; // ─── Provider form (create / edit) ─────────────────────────────────────────── function ProviderForm({ initial, isEdit = false, groups = [], onSave, onCancel, isPending, }: { initial: OIDCProviderCreate; isEdit?: boolean; groups?: Group[]; onSave: (data: OIDCProviderCreate) => void; onCancel: () => void; isPending: boolean; }) { const { t } = useTranslation(); const [form, setForm] = useState(initial); const [secretChanged, setSecretChanged] = useState(false); const set = (key: keyof OIDCProviderCreate, value: unknown) => setForm((prev) => ({ ...prev, [key]: value })); const inputCls = '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'; const labelCls = 'block text-sm font-medium text-white mb-1'; const handleSave = () => { const payload = { ...form }; if (isEdit && !secretChanged) { delete (payload as Partial).client_secret; } onSave(payload); }; const autoLinkOn = form.auto_link_existing_accounts === true; const emailVerifiedOn = form.require_email_verified ?? true; let requireEmailVerifiedDesc: ReactNode; if (autoLinkOn) { requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedAutoLink'); } else if (emailVerifiedOn) { requireEmailVerifiedDesc = t('settings.oidc.form.requireEmailVerifiedDesc'); } else { requireEmailVerifiedDesc = ( {t('settings.oidc.form.requireEmailVerifiedWarning')} ); } return (
set('name', e.target.value)} placeholder="Google" />
set('issuer_url', e.target.value)} placeholder="https://accounts.google.com" />
set('client_id', e.target.value)} placeholder="your-client-id" />
{ setSecretChanged(true); set('client_secret', e.target.value); }} />
set('scopes', e.target.value)} placeholder="openid email profile" />
set('icon_url', e.target.value === '' ? null : e.target.value)} placeholder="https://..." />
set('email_claim', e.target.value || 'email')} placeholder={t('settings.oidc.form.emailClaimPlaceholder')} />

{t('settings.oidc.form.emailClaimDesc')}

{autoLinkOn && form.email_claim !== 'email' && (

{t('settings.oidc.form.emailClaimCustomClaimAutoLinkWarning')}

)}

{t('settings.oidc.form.defaultGroupDesc')}

); } /** * Per-provider icon avatar in the admin Settings list (#1333 review). * * Extracted so each card has its own `iconFailed` state. Previously * `onError` just set `display: none` and the admin saw an unexplained gap * where the icon should be — now we swap in the Globe fallback exactly * like the `has_icon === false` branch, so the visual state is * self-explanatory regardless of why the icon didn't load. */ function ProviderIconAvatar({ provider }: { provider: OIDCProvider }) { const [iconFailed, setIconFailed] = useState(false); const showIcon = provider.has_icon && !iconFailed; if (showIcon) { return ( {provider.name} setIconFailed(true)} /> ); } return (
); } // ─── Main component ─────────────────────────────────────────────────────────── export function OIDCProviderSettings() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [showCreate, setShowCreate] = useState(false); const [editingId, setEditingId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const { data: providers, isLoading } = useQuery({ queryKey: ['oidc-providers-all'], queryFn: () => api.getOIDCProvidersAll(), }); const { data: groups = [] } = useQuery({ queryKey: ['groups'], queryFn: () => api.getGroups(), }); const createMutation = useMutation({ mutationFn: (data: OIDCProviderCreate) => api.createOIDCProvider(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] }); setShowCreate(false); showToast(t('settings.oidc.created'), 'success'); }, onError: (e: Error) => showToast(e.message, 'error'), }); const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: Partial }) => api.updateOIDCProvider(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] }); setEditingId(null); showToast(t('settings.oidc.updated'), 'success'); }, onError: (e: Error) => showToast(e.message, 'error'), }); const deleteMutation = useMutation({ mutationFn: (id: number) => api.deleteOIDCProvider(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] }); setDeleteTarget(null); showToast(t('settings.oidc.deleted'), 'success'); }, onError: (e: Error) => showToast(e.message, 'error'), }); // Icon-proxy mutations (#1333). Refresh re-fetches from the stored // icon_url; remove clears the cached bytes but keeps icon_url. const refreshIconMutation = useMutation({ mutationFn: (id: number) => api.refreshOIDCProviderIcon(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] }); queryClient.invalidateQueries({ queryKey: ['oidc-providers'] }); showToast(t('settings.oidc.iconRefreshed'), 'success'); }, onError: (e: Error) => showToast(e.message || t('settings.oidc.iconFetchFailed'), 'error'), }); const removeIconMutation = useMutation({ mutationFn: (id: number) => api.deleteOIDCProviderIcon(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['oidc-providers-all'] }); queryClient.invalidateQueries({ queryKey: ['oidc-providers'] }); showToast(t('settings.oidc.iconRemoved'), 'success'); }, onError: (e: Error) => showToast(e.message, 'error'), }); const toggleEnabled = (provider: OIDCProvider) => updateMutation.mutate({ id: provider.id, data: { is_enabled: !provider.is_enabled } }); if (isLoading) { return (
); } return (
{/* Header */}

{t('settings.oidc.title')}

{t('settings.oidc.desc')}

{!showCreate && ( )}
{showCreate && (

{t('settings.oidc.newProvider')}

createMutation.mutate(data)} onCancel={() => setShowCreate(false)} isPending={createMutation.isPending} />
)}
{/* Provider list */} {providers && providers.length === 0 && !showCreate && (

{t('settings.oidc.empty')}

)} {providers?.map((provider) => (

{provider.name}

{provider.is_enabled ? ( {t('common.enabled')} ) : ( {t('common.disabled')} )}
{provider.issuer_url}
{provider.icon_url && ( )} {provider.has_icon && ( )} toggleEnabled(provider)} disabled={updateMutation.isPending} />
{editingId === provider.id && (
updateMutation.mutate({ id: provider.id, data })} onCancel={() => setEditingId(null)} isPending={updateMutation.isPending} />
)} {editingId !== provider.id && (
{t('settings.oidc.form.clientId')}
{provider.client_id}
{t('settings.oidc.form.scopes')}
{provider.scopes}
{t('settings.oidc.form.autoCreate')}
{provider.auto_create_users ? t('common.yes') : t('common.no')}
{t('settings.oidc.form.autoLink')}
{provider.auto_link_existing_accounts ? t('common.yes') : t('common.no')}
{t('settings.oidc.form.emailClaim')}
{provider.email_claim}
{t('settings.oidc.form.requireEmailVerified')}
{provider.require_email_verified ? t('common.yes') : t('common.no')}
{t('settings.oidc.form.defaultGroup')}
{provider.default_group_id ? (groups.find((g) => g.id === provider.default_group_id)?.name ?? t('settings.oidc.form.defaultGroupViewersFallback')) : t('settings.oidc.form.defaultGroupViewersFallback')}
)}
))} {/* Delete confirm */} {deleteTarget && ( deleteMutation.mutate(deleteTarget.id)} onCancel={() => setDeleteTarget(null)} /> )}
); }