import { useState, useEffect, useCallback, useMemo } from 'react' import { Mail, Globe, MessageCircle, Send, Link2, Unlink, Loader2, Eye, EyeOff, } from 'lucide-react' import { useTranslation } from 'react-i18next' import { SiGithub, SiDiscord } from 'react-icons/si' import { toast } from 'sonner' import { api } from '@/lib/api' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { ScrollArea } from '@/components/ui/scroll-area' import { Separator } from '@/components/ui/separator' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge } from '@/components/status-badge' import { getUser, getUserOAuthBindings, adminClearUserBinding, adminUnbindCustomOAuth, type OAuthBinding, } from '../../api' import type { User } from '../../types' interface Props { open: boolean onOpenChange: (open: boolean) => void userId: number | null onUnbindSuccess?: () => void } interface BindingItem { key: string label: string icon: React.ReactNode value: string type: 'builtin' | 'custom' providerId?: string isBound: boolean isEnabled: boolean } interface StatusInfo { github_oauth?: boolean discord_oauth?: boolean oidc_enabled?: boolean wechat_login?: boolean telegram_oauth?: boolean linuxdo_oauth?: boolean custom_oauth_providers?: Array<{ id: string name: string icon?: string }> } const BUILTIN_BINDINGS: ReadonlyArray<{ key: string field: string label: string icon: React.ReactNode statusKey: keyof StatusInfo | null }> = [ { key: 'email', field: 'email', label: 'Email', icon: , statusKey: null, }, { key: 'github_id', field: 'github_id', label: 'GitHub', icon: , statusKey: 'github_oauth', }, { key: 'discord_id', field: 'discord_id', label: 'Discord', icon: , statusKey: 'discord_oauth', }, { key: 'wechat_id', field: 'wechat_id', label: 'WeChat', icon: , statusKey: 'wechat_login', }, { key: 'oidc_id', field: 'oidc_id', label: 'OIDC', icon: , statusKey: 'oidc_enabled', }, { key: 'telegram_id', field: 'telegram_id', label: 'Telegram', icon: , statusKey: 'telegram_oauth', }, { key: 'linux_do_id', field: 'linux_do_id', label: 'LinuxDO', icon: , statusKey: 'linuxdo_oauth', }, ] function CustomProviderIcon(props: { iconUrl?: string }) { if (!props.iconUrl) return return ( { e.currentTarget.style.display = 'none' }} /> ) } export function UserBindingDialog(props: Props) { const { t } = useTranslation() const [user, setUser] = useState(null) const [oauthBindings, setOauthBindings] = useState([]) const [statusInfo, setStatusInfo] = useState({}) const [loading, setLoading] = useState(false) const [showBoundOnly, setShowBoundOnly] = useState(true) const [unbindTarget, setUnbindTarget] = useState(null) const [unbinding, setUnbinding] = useState(false) const fetchData = useCallback(async () => { if (!props.userId) return setLoading(true) try { const [userRes, oauthRes, statusRes] = await Promise.all([ getUser(props.userId), getUserOAuthBindings(props.userId).catch(() => ({ success: false, data: [], })), api .get('/api/status') .then((r) => r.data) .catch(() => ({ success: false, data: {}, })), ]) if (userRes.success && userRes.data) { setUser(userRes.data) } if (oauthRes.success && oauthRes.data) { setOauthBindings(oauthRes.data as OAuthBinding[]) } if (statusRes.success && statusRes.data) { setStatusInfo(statusRes.data as StatusInfo) } } catch { toast.error(t('Failed to load')) } finally { setLoading(false) } }, [props.userId, t]) useEffect(() => { if (props.open && props.userId) { setShowBoundOnly(true) fetchData() } else { setUser(null) setOauthBindings([]) setStatusInfo({}) } }, [props.open, props.userId, fetchData]) const allBindings = useMemo(() => { const items: BindingItem[] = [] for (const field of BUILTIN_BINDINGS) { const value = user ? String((user as Record)[field.field] || '') : '' const isBound = !!value const isEnabled = field.statusKey == null ? true : Boolean(statusInfo[field.statusKey]) items.push({ key: field.key, label: field.label, icon: field.icon, value: isBound ? value : '', type: 'builtin', isBound, isEnabled, }) } const oauthBindingMap = new Map( oauthBindings.map((b) => [String(b.provider_id), b]) ) const customProviders = statusInfo.custom_oauth_providers || [] const seenProviderIds = new Set() for (const provider of customProviders) { seenProviderIds.add(String(provider.id)) const binding = oauthBindingMap.get(String(provider.id)) items.push({ key: `oauth_${provider.id}`, label: provider.name || provider.id, icon: , value: binding?.external_id || '', type: 'custom', providerId: String(provider.id), isBound: !!binding, isEnabled: true, }) } for (const binding of oauthBindings) { if (!seenProviderIds.has(String(binding.provider_id))) { items.push({ key: `oauth_${binding.provider_id}`, label: binding.provider_name || binding.provider_id, icon: , value: binding.external_id || '-', type: 'custom', providerId: String(binding.provider_id), isBound: true, isEnabled: false, }) } } return items }, [user, oauthBindings, statusInfo]) const displayedBindings = showBoundOnly ? allBindings.filter((b) => b.isBound) : allBindings const boundCount = allBindings.filter((b) => b.isBound).length const handleUnbind = async () => { if (!unbindTarget || !props.userId) return setUnbinding(true) try { let res if (unbindTarget.type === 'builtin') { res = await adminClearUserBinding(props.userId, unbindTarget.key) } else if (unbindTarget.providerId) { res = await adminUnbindCustomOAuth( props.userId, unbindTarget.providerId ) } if (res?.success) { toast.success( t('Unbound {{provider}}', { provider: unbindTarget.label }) ) await fetchData() props.onUnbindSuccess?.() } else { toast.error(res?.message || t('Unbind failed')) } } catch { toast.error(t('Unbind failed')) } finally { setUnbinding(false) setUnbindTarget(null) } } return ( <> {t('Account Binding Management')} {t('Manage account bindings for this user')} {loading ? (
) : (
{user && (

{user.username} (ID: {user.id})

)} {showBoundOnly ? t('Show all providers including unbound') : t('Show only bound providers')}
{displayedBindings.length === 0 ? (

{showBoundOnly ? t('This user has no bindings') : t('No providers available')}

) : (
{displayedBindings.map((binding) => (
{binding.icon}
{binding.label} {!binding.isEnabled && ( )}

{binding.isBound ? binding.value : t('Not bound')}

{binding.isBound && ( )}
))}
)}

{t('Bound')}: {boundCount} / {allBindings.length}

)}
!open && setUnbindTarget(null)} title={t('Confirm Unbind')} desc={t( 'Are you sure you want to unbind {{provider}} for this user? The user will no longer be able to log in via this method.', { provider: unbindTarget?.label || '', } )} confirmText={t('Confirm Unbind')} destructive handleConfirm={handleUnbind} isLoading={unbinding} /> ) }