/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useEffect, useMemo, useState, useCallback } from 'react' import { Mail, Shield, Send, Link2, Unlink } from 'lucide-react' import { useTranslation } from 'react-i18next' import { SiGithub, SiWechat, SiLinux } from 'react-icons/si' import { toast } from 'sonner' import { IconDiscord } from '@/assets/brand-icons' import { handleGitHubOAuth, handleOIDCOAuth, handleDiscordOAuth, handleLinuxDOOAuth, } from '@/lib/oauth' import { useDialogs } from '@/hooks/use-dialog' import { useStatus } from '@/hooks/use-status' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { ConfirmDialog } from '@/components/confirm-dialog' import { StatusBadge } from '@/components/status-badge' import { OAUTH_BIND_STORAGE_KEY } from '@/features/auth/constants' import { getSelfOAuthBindings, unbindCustomOAuth, type CustomOAuthBinding, } from '../../api' import type { UserProfile, BindingItem } from '../../types' import { EmailBindDialog } from '../dialogs/email-bind-dialog' import { TelegramBindDialog } from '../dialogs/telegram-bind-dialog' import { WeChatBindDialog } from '../dialogs/wechat-bind-dialog' // ============================================================================ // Account Bindings Tab Component // ============================================================================ interface AccountBindingsTabProps { profile: UserProfile | null onUpdate: () => void } type DialogKey = 'email' | 'wechat' | 'telegram' export function AccountBindingsTab({ profile, onUpdate, }: AccountBindingsTabProps) { const { t } = useTranslation() const dialogs = useDialogs() const { status, loading } = useStatus() const [customBindings, setCustomBindings] = useState([]) const [unbindTarget, setUnbindTarget] = useState( null ) const [unbinding, setUnbinding] = useState(false) const customProviders = status?.custom_oauth_providers as | Array<{ id: string; name: string }> | undefined const fetchCustomBindings = useCallback(async () => { if (!customProviders || customProviders.length === 0) return try { const res = await getSelfOAuthBindings() if (res.success && res.data) { setCustomBindings(res.data) } } catch { // ignore } }, [customProviders]) useEffect(() => { fetchCustomBindings() }, [fetchCustomBindings]) const handleUnbindCustom = async () => { if (!unbindTarget) return setUnbinding(true) try { const res = await unbindCustomOAuth(unbindTarget.provider_id) if (res.success) { toast.success( t('Unbound {{provider}}', { provider: unbindTarget.provider_name, }) ) await fetchCustomBindings() onUpdate() } else { toast.error(res.message || t('Unbind failed')) } } catch { toast.error(t('Unbind failed')) } finally { setUnbinding(false) setUnbindTarget(null) } } const handleBindCustomOAuth = (provider: { id: string; name: string }) => { const redirectUrl = `${window.location.origin}/oauth/${provider.id}?bind=true` window.location.href = `/api/oauth/${provider.id}?redirect=${encodeURIComponent(redirectUrl)}` } useEffect(() => { if (typeof window === 'undefined') return const handleStorage = (event: StorageEvent) => { if (event.key !== OAUTH_BIND_STORAGE_KEY || !event.newValue) return try { const payload = JSON.parse(event.newValue) as { status?: string provider?: string timestamp?: number } if (payload?.status === 'success') { onUpdate() } } catch { // ignore malformed payloads } try { window.localStorage.removeItem(OAUTH_BIND_STORAGE_KEY) } catch { // ignore cleanup failure } } window.addEventListener('storage', handleStorage) return () => window.removeEventListener('storage', handleStorage) }, [onUpdate]) // Memoize bindings to prevent unnecessary recalculations const bindings: BindingItem[] = useMemo(() => { if (!profile || !status) return [] return [ { id: 'email', label: t('Email'), icon: Mail, value: profile.email, isBound: Boolean(profile.email), isEnabled: true, onBind: () => dialogs.open('email'), }, { id: 'wechat', label: t('WeChat'), icon: SiWechat as React.ComponentType<{ className?: string }>, value: undefined, isBound: Boolean( (profile as unknown as Record).wechat_id ), isEnabled: status?.wechat_login || false, onBind: () => dialogs.open('wechat'), }, { id: 'github', label: t('GitHub'), icon: SiGithub, value: (profile as unknown as Record).github_id as | string | undefined, isBound: Boolean( (profile as unknown as Record).github_id ), isEnabled: status?.github_oauth || false, onBind: () => { if (status?.github_client_id) { handleGitHubOAuth(status.github_client_id) } }, }, { id: 'discord', label: t('Discord'), icon: IconDiscord, value: (profile as unknown as Record).discord_id as | string | undefined, isBound: Boolean( (profile as unknown as Record).discord_id ), isEnabled: status?.discord_oauth || false, onBind: () => { if (status?.discord_client_id) { handleDiscordOAuth(status.discord_client_id) } }, }, { id: 'oidc', label: t('OIDC'), icon: Shield, value: (profile as unknown as Record).oidc_id as | string | undefined, isBound: Boolean( (profile as unknown as Record).oidc_id ), isEnabled: status?.oidc_enabled || false, onBind: () => { if (status?.oidc_authorization_endpoint && status?.oidc_client_id) { handleOIDCOAuth( status.oidc_authorization_endpoint, status.oidc_client_id ) } }, }, { id: 'telegram', label: t('Telegram'), icon: Send, value: (profile as unknown as Record).telegram_id as | string | undefined, isBound: Boolean( (profile as unknown as Record).telegram_id ), isEnabled: status?.telegram_oauth || false, onBind: () => dialogs.open('telegram'), }, { id: 'linuxdo', label: t('LinuxDO'), icon: SiLinux as React.ComponentType<{ className?: string }>, value: (profile as unknown as Record).linux_do_id as | string | undefined, isBound: Boolean( (profile as unknown as Record).linux_do_id ), isEnabled: status?.linuxdo_oauth || false, onBind: () => { if (status?.linuxdo_client_id) { handleLinuxDOOAuth(status.linuxdo_client_id) } }, }, ].filter((binding) => binding.isEnabled) // eslint-disable-next-line react-hooks/exhaustive-deps }, [profile, status, t]) if (!profile || loading) return null return ( <> {bindings.map((binding) => ( {binding.label} {binding.isBound && ( )} {binding.value || t('Not bound')} {binding.isBound ? binding.id === 'email' ? t('Change') : t('Bound') : t('Bind')} ))} {/* Custom OAuth Bindings */} {customProviders && customProviders.length > 0 && ( <> {t('Custom OAuth')} {customProviders.map((provider) => { const binding = customBindings.find( (b) => b.provider_id === provider.id ) const isBound = !!binding return ( {provider.name} {isBound && ( )} {isBound ? binding?.external_id || t('Bound') : t('Not bound')} {isBound ? ( setUnbindTarget(binding)} > {t('Unbind')} ) : ( handleBindCustomOAuth(provider)} > {t('Bind')} )} ) })} > )} {/* Custom OAuth Unbind Confirmation */} !open && setUnbindTarget(null)} title={t('Confirm Unbind')} desc={t( 'Are you sure you want to unbind {{provider}}? You will no longer be able to log in via this method.', { provider: unbindTarget?.provider_name || '', } )} confirmText={t('Confirm Unbind')} destructive handleConfirm={handleUnbindCustom} isLoading={unbinding} /> {/* Email Bind Dialog */} open ? dialogs.open('email') : dialogs.close('email') } currentEmail={profile.email} onSuccess={onUpdate} /> {/* WeChat Bind Dialog */} open ? dialogs.open('wechat') : dialogs.close('wechat') } onSuccess={onUpdate} /> {/* Telegram Bind Dialog */} {status?.telegram_bot_name && ( open ? dialogs.open('telegram') : dialogs.close('telegram') } botName={status.telegram_bot_name as string} onSuccess={onUpdate} /> )} > ) }
{binding.label}
{binding.value || t('Not bound')}
{t('Custom OAuth')}
{provider.name}
{isBound ? binding?.external_id || t('Bound') : t('Not bound')}