diff --git a/bot/src/__init__.py b/bot/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/src/commands/music_controls.py b/bot/src/commands/music_controls.py index 514155d..8c73d81 100644 --- a/bot/src/commands/music_controls.py +++ b/bot/src/commands/music_controls.py @@ -28,19 +28,17 @@ from src.services.dj_permission_service import DJPermissionManager from src.services.queue_telemetry_service import QueueTelemetryService from src.services.profile_service import GuildProfileManager + from src.services.queue_sync_service import QueueSyncService + from src.services.shard_supervisor import ShardSupervisor + from src.services.alert_service import AlertService + from src.services.automation_audit_service import AutomationAuditService + from src.services.command_throttle_service import CommandThrottleService + from src.services.analytics_export_service import AnalyticsExportService URL_REGEX = re.compile(r"https?://", re.IGNORECASE) VOICE_PERMISSIONS = ("connect", "speak", "view_channel") -def ms_to_clock(ms: int) -> str: - """Convert milliseconds into a human readable duration string.""" - seconds = max(0, int(ms // 1000)) - minutes, secs = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - if hours: - return f"{hours:d}:{minutes:02d}:{secs:02d}" - return f"{minutes:d}:{secs:02d}" class MusicControls(commands.Cog): diff --git a/bot/src/commands/queue_commands.py b/bot/src/commands/queue_commands.py index 9e0a96e..5c5d380 100644 --- a/bot/src/commands/queue_commands.py +++ b/bot/src/commands/queue_commands.py @@ -14,6 +14,7 @@ from src.services.playlist_service import PlaylistService, PlaylistStorageError from src.services.server_settings_service import QueueCapacity from src.utils.embeds import EmbedFactory +from src.utils.time import ms_to_clock from src.utils.progress import SlashProgress from src.utils.pagination import EmbedPaginator @@ -28,26 +29,7 @@ from src.services.analytics_export_service import AnalyticsExportService from src.services.queue_copilot_service import QueueCopilotService -if TYPE_CHECKING: - from src.services.dj_permission_service import DJPermissionManager - from src.services.server_settings_service import ServerSettingsService - from src.services.queue_sync_service import QueueSyncService - from src.services.shard_supervisor import ShardSupervisor - from src.services.alert_service import AlertService - from src.services.automation_audit_service import AutomationAuditService - from src.services.command_throttle_service import CommandThrottleService - from src.services.analytics_export_service import AnalyticsExportService - from src.services.queue_copilot_service import QueueCopilotService - -def ms_to_clock(ms: int) -> str: - """Convert milliseconds to ``H:MM:SS`` or ``M:SS`` for queue displays.""" - seconds = max(0, int(ms // 1000)) - minutes, sec = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - if hours: - return f"{hours:d}:{minutes:02d}:{sec:02d}" - return f"{minutes:d}:{sec:02d}" def track_str(track: lavalink.AudioTrack) -> str: diff --git a/bot/src/utils/time.py b/bot/src/utils/time.py new file mode 100644 index 0000000..5cf75b6 --- /dev/null +++ b/bot/src/utils/time.py @@ -0,0 +1,8 @@ +def ms_to_clock(ms: int) -> str: + """Convert milliseconds into a human readable duration string.""" + seconds = max(0, int(ms // 1000)) + minutes, secs = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours:d}:{minutes:02d}:{secs:02d}" + return f"{minutes:d}:{secs:02d}" diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx index a1c1f95..37e7453 100644 --- a/frontend/app/account/page.tsx +++ b/frontend/app/account/page.tsx @@ -1,7 +1,8 @@ -'use client' +"use client" -import { useEffect, useCallback } from "react" +import { useState, useEffect, useCallback, type ComponentType } from "react" import { useRouter } from "next/navigation" +import Link from "next/link" import Navigation from "@/components/navigation" import Footer from "@/components/footer" import { @@ -10,465 +11,2431 @@ import { Shield, Bell, CreditCard, + LogOut, + Link2 as LinkIcon, + SlidersHorizontal, } from "lucide-react" -import { useAccountState } from "@/hooks/useAccountState" -import { useAccountApi } from "@/hooks/useAccountApi" -import { SettingsCard, SettingsInput, SettingsCheckbox, SettingsButton } from "@/components/settings" - -const TABS = [ - { id: 'profile', label: 'Profile', icon: User }, - { id: 'security', label: 'Security', icon: Lock }, - { id: 'privacy', label: 'Privacy', icon: Shield }, - { id: 'notifications', label: 'Notifications', icon: Bell }, - { id: 'billing', label: 'Billing', icon: CreditCard }, +import { + SiDiscord, + SiFaceit, + SiGithub, + SiGitlab, + SiInstagram, + SiSlack, + SiSteam, + SiTiktok, + SiTwitch, + SiX, + SiYoutube, +} from "react-icons/si" +import { FaMicrosoft } from "react-icons/fa" +import { buildDiscordLoginUrl } from "@/lib/config" +import { RoleBadge } from "@/components/role-badge" + +const PREFERENCE_DEFAULTS = { + preferredLanguage: "en", + fullName: "", + birthDate: "", + addressCountry: "", + addressState: "", + addressCity: "", + addressStreet: "", + addressHouseNumber: "", + addressPostalCode: "", +} + +const NOTIFICATION_DEFAULTS = { + maintenanceAlerts: true, + downtimeAlerts: true, + releaseNotes: true, + securityNotifications: true, + betaProgram: false, + communityEvents: false, +} + +const PRIVACY_DEFAULTS = { + profilePublic: false, + searchVisibility: true, + analyticsOptIn: false, + dataSharing: false, +} + +const PROFILE_FORM_DEFAULTS = { + displayName: "", + headline: "", + bio: "", + location: "", + website: "", + handle: "", +} + +const SECURITY_DEFAULTS = { + twoFactorEnabled: false, + loginAlerts: true, + backupCodesRemaining: 5, + activeSessions: 1, + lastPasswordChange: null as string | null, +} + +type LinkedProviderDefinition = { + value: string + label: string + icon: ComponentType<{ className?: string }> + placeholder: string + helper?: string + urlBased?: boolean +} + +const buildGuildManageHref = (guildId?: string | null) => { + if (typeof guildId !== "string" || !guildId.trim()) { + return "/control-panel" + } + return `/control-panel?guild=${encodeURIComponent(guildId)}` +} + +const LINKED_ACCOUNT_PROVIDERS: LinkedProviderDefinition[] = [ + { + value: "youtube", + label: "YouTube Channel", + icon: SiYoutube, + placeholder: "youtube.com/@vectobeat", + helper: "Paste the channel URL or handle that should appear on your profile.", + urlBased: true, + }, + { + value: "instagram", + label: "Instagram", + icon: SiInstagram, + placeholder: "instagram.com/your-handle", + urlBased: true, + }, + { + value: "x", + label: "X (Twitter)", + icon: SiX, + placeholder: "x.com/your-handle", + urlBased: true, + }, + { + value: "tiktok", + label: "TikTok", + icon: SiTiktok, + placeholder: "tiktok.com/@your-handle", + urlBased: true, + }, + { + value: "twitch", + label: "Twitch", + icon: SiTwitch, + placeholder: "twitch.tv/channel", + urlBased: true, + }, + { + value: "faceit", + label: "FACEIT", + icon: SiFaceit, + placeholder: "faceit.com/en/players/handle", + urlBased: true, + }, + { + value: "steam", + label: "Steam", + icon: SiSteam, + placeholder: "steamcommunity.com/id/handle", + urlBased: true, + }, + { + value: "github", + label: "GitHub", + icon: SiGithub, + placeholder: "github.com/username", + urlBased: true, + }, + { + value: "gitlab", + label: "GitLab", + icon: SiGitlab, + placeholder: "gitlab.com/username", + urlBased: true, + }, + { + value: "slack", + label: "Slack Workspace", + icon: SiSlack, + placeholder: "workspace.slack.com", + helper: "Use the workspace URL so we can deep-link teammates directly.", + urlBased: true, + }, + { + value: "microsoft", + label: "Microsoft (Teams/Azure)", + icon: FaMicrosoft, + placeholder: "tenant.onmicrosoft.com", + urlBased: true, + }, + { + value: "discord_alt", + label: "Secondary Discord ID", + icon: SiDiscord, + placeholder: "Discord user ID (snowflake)", + helper: "Perfect for co-admins who help manage billing or automation.", + urlBased: false, + }, ] -export default function AccountPage() { - const router = useRouter() - const { state, setLoadingState, setErrorState, setMessageState, updateFormData, updatePreferences, setActiveTab, setDiscordId } = useAccountState() - const api = useAccountApi() +const DEFAULT_LINKED_PROVIDER = LINKED_ACCOUNT_PROVIDERS[0]?.value ?? "discord_alt" - // Load user data on mount - useEffect(() => { - const loadUserData = async () => { - const discordId = '123456789' // This should come from your auth context - if (!discordId) return +const formatProviderLabel = (value: string) => + value + .split(/[_-]/) + .filter(Boolean) + .map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1)) + .join(" ") - setDiscordId(discordId) - setLoadingState('isLoading', true) - +const maskDiscordId = (value: string) => { + if (!value) return "" + if (value.length <= 4) { + return "*".repeat(Math.max(value.length, 1)) + } + return `${"*".repeat(value.length - 4)}${value.slice(-4)}` +} + +type NotificationState = typeof NOTIFICATION_DEFAULTS +type PrivacyState = typeof PRIVACY_DEFAULTS +type SecurityState = typeof SECURITY_DEFAULTS +type ActiveSessionState = { + id: string + ipAddress: string | null + userAgent: string | null + location: string | null + createdAt: string + lastActive: string + revoked: boolean +} + +type AccountSubscription = { + id: string + name: string + tier: string + status: string + currentPeriodStart: string + currentPeriodEnd: string + pricePerMonth: number + discordServerId: string + stripeCustomerId?: string | null +} + +const RENEWAL_NOTICE_WINDOW_MS = 3 * 24 * 60 * 60 * 1000 + +const shouldShowRenewalPrompt = (subscription: AccountSubscription) => { + if (!subscription) return false + if (subscription.status === "canceled") return false + if (subscription.status === "past_due") return true + const endTimestamp = new Date(subscription.currentPeriodEnd).getTime() + if (Number.isNaN(endTimestamp)) { + return false + } + return endTimestamp - Date.now() <= RENEWAL_NOTICE_WINDOW_MS +} + +const getTierBadgeColor = (tier: string) => { + switch (tier) { + case "starter": + return "bg-blue-500/20 text-blue-400 border-blue-500/30" + case "pro": + return "bg-primary/20 text-primary border-primary/30" + case "enterprise": + return "bg-purple-500/20 text-purple-400 border-purple-500/30" + default: + return "bg-gray-500/20 text-gray-400 border-gray-500/30" + } +} + +const getStatusBadgeColor = (status: string) => { + switch (status) { + case "active": + return "bg-green-500/20 text-green-500" + case "canceled": + return "bg-red-500/20 text-red-400" + case "pending": + return "bg-yellow-500/20 text-yellow-500" + case "past_due": + return "bg-orange-500/20 text-orange-500" + default: + return "bg-gray-500/20 text-gray-400" + } +} + +export default function AccountPage() { + const appRouter = useRouter() + const [activeTab, setActiveTab] = useState("profile") + const [loading, setLoading] = useState(true) + const [isAuthorized, setIsAuthorized] = useState(false) + const [authError, setAuthError] = useState(null) + const [authToken, setAuthToken] = useState(null) + const [userRole, setUserRole] = useState("member") + const [formData, setFormData] = useState({ + email: "", + username: "", + phone: "", + discordId: "", + }) + const [currentSessionId, setCurrentSessionId] = useState(null) + const [contactLoading, setContactLoading] = useState(false) + const [contactSaving, setContactSaving] = useState(false) + const [contactMessage, setContactMessage] = useState(null) + const [contactError, setContactError] = useState(null) + const [preferences, setPreferences] = useState(() => ({ ...PREFERENCE_DEFAULTS })) + const [languageSaving, setLanguageSaving] = useState(false) + const [languageError, setLanguageError] = useState(null) + const [billingSaving, setBillingSaving] = useState(false) + const [billingError, setBillingError] = useState(null) + const [billingMessage, setBillingMessage] = useState(null) + const [notifications, setNotifications] = useState(() => ({ ...NOTIFICATION_DEFAULTS })) + const [notificationsLoading, setNotificationsLoading] = useState(true) + const [notificationsSaving, setNotificationsSaving] = useState(false) + const [notificationsError, setNotificationsError] = useState(null) + const [privacy, setPrivacy] = useState(() => ({ ...PRIVACY_DEFAULTS })) + const [privacyLoading, setPrivacyLoading] = useState(true) + const [privacySaving, setPrivacySaving] = useState(false) + const [privacyError, setPrivacyError] = useState(null) + const [security, setSecurity] = useState(() => ({ ...SECURITY_DEFAULTS })) + const [securityLoading, setSecurityLoading] = useState(true) + const [securitySaving, setSecuritySaving] = useState(false) + const [securityError, setSecurityError] = useState(null) + const [sessions, setSessions] = useState([]) + const [sessionsLoading, setSessionsLoading] = useState(false) + const [sessionsError, setSessionsError] = useState(null) + const [backupCodes, setBackupCodes] = useState([]) + const [backupCodesVisible, setBackupCodesVisible] = useState(false) + const [backupCodesLoading, setBackupCodesLoading] = useState(false) + const [backupCodesFetched, setBackupCodesFetched] = useState(false) + const [backupCodesError, setBackupCodesError] = useState(null) + const [downloadingData, setDownloadingData] = useState(false) + const [linkedAccounts, setLinkedAccounts] = useState< + Array<{ id: string; provider: string; handle: string; createdAt: string }> + >([]) + const [linkedAccountsLoading, setLinkedAccountsLoading] = useState(true) + const [linkedAccountsError, setLinkedAccountsError] = useState(null) +const [linkedAccountForm, setLinkedAccountForm] = useState({ + provider: DEFAULT_LINKED_PROVIDER, + handle: "", +}) +const [linkedAccountSaving, setLinkedAccountSaving] = useState(false) +const [showPrimaryDiscordId, setShowPrimaryDiscordId] = useState(false) +const [profileMessage, setProfileMessage] = useState(null) + const [profileMeta, setProfileMeta] = useState<{ username: string; displayName: string; avatarUrl: string | null }>({ + username: "", + displayName: "", + avatarUrl: null, + }) + const [guildMetrics, setGuildMetrics] = useState({ + membershipCount: 0, + adminGuildCount: 0, + botGuildCount: 0, + }) + const [profileSettings, setProfileSettings] = useState({ ...PROFILE_FORM_DEFAULTS }) + const [profileSettingsLoading, setProfileSettingsLoading] = useState(false) + const [profileSettingsSaving, setProfileSettingsSaving] = useState(false) + const [profileSettingsError, setProfileSettingsError] = useState(null) +const [subscriptions, setSubscriptions] = useState([]) + const [subscriptionsLoading, setSubscriptionsLoading] = useState(true) + const [subscriptionsError, setSubscriptionsError] = useState(null) + const [renewingSubscriptionId, setRenewingSubscriptionId] = useState(null) +const [subscriptionPreview, setSubscriptionPreview] = useState(null) + const subscriptionOwnerName = profileMeta.displayName || profileMeta.username || formData.username || "You" + const formatDate = (value?: string | null) => (value ? new Date(value).toLocaleDateString("en-US") : "—") + const formatEuros = (amount: number) => + new Intl.NumberFormat("en-US", { style: "currency", currency: "EUR" }).format(amount) + const handleSubscriptionPayment = useCallback( + async (subscription: AccountSubscription) => { + if (!formData.discordId) return + setRenewingSubscriptionId(subscription.id) + setSubscriptionsError(null) try { - const [contactData, preferencesData, notificationData, privacyData] = await Promise.all([ - api.fetchContactInfo(discordId), - api.fetchPreferences(discordId), - api.fetchNotifications(discordId), - api.fetchPrivacySettings(discordId) - ]) - - updateFormData({ - email: contactData.email || '', - phone: contactData.phone || '', - language: preferencesData.language ?? 'en', - timezone: preferencesData.timezone ?? 'UTC', - currency: preferencesData.currency ?? 'USD' - }) - - updatePreferences({ - theme: preferencesData.theme ?? state.preferences.theme, - compactMode: preferencesData.compactMode ?? state.preferences.compactMode, - reducedMotion: preferencesData.reducedMotion ?? state.preferences.reducedMotion + const response = await fetch("/api/billing/portal", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ discordId: formData.discordId }), }) - - if (notificationData) { - updateFormData({ notifications: notificationData }) + if (!response.ok) { + const payload = await response.json().catch(() => ({})) + throw new Error(payload.error || "Unable to open billing portal.") } - - if (privacyData) { - updateFormData({ privacy: privacyData }) + const data = await response.json() + if (!data?.url) { + throw new Error("Billing portal unavailable.") } + window.location.href = data.url } catch (error) { - console.error('Failed to load user data:', error) - setErrorState('contactError', 'Failed to load user data') + console.error("Failed to open billing portal:", error) + setSubscriptionsError(error instanceof Error ? error.message : "Unable to open billing portal.") } finally { - setLoadingState('isLoading', false) + setRenewingSubscriptionId(null) } + }, + [formData.discordId], + ) + const loginHref = + typeof window !== "undefined" + ? buildDiscordLoginUrl(`${window.location.origin}/api/auth/discord/callback`) + : buildDiscordLoginUrl() + const profileShareSlug = (profileSettings.handle || profileMeta.username || formData.discordId || "").trim() + const profileShareBase = + typeof window !== "undefined" + ? window.location.origin + : process.env.NEXT_PUBLIC_URL || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000") +const profileShareUrl = profileShareSlug + ? `${profileShareBase.replace(/\/$/, "")}/profile/${profileShareSlug}` + : "" + const activeLinkedProvider = + LINKED_ACCOUNT_PROVIDERS.find((provider) => provider.value === linkedAccountForm.provider) ?? + LINKED_ACCOUNT_PROVIDERS[0] + const otherDeviceCount = currentSessionId + ? sessions.filter((session) => session.id !== currentSessionId).length + : Math.max(sessions.length - (sessions.length ? 1 : 0), 0) + + const fetchContactInfo = useCallback(async (discordId: string) => { + setContactLoading(true) + setContactError(null) + try { + const response = await fetch(`/api/account/contact?discordId=${discordId}`, { + cache: "no-store", + }) + if (!response.ok) { + throw new Error("Failed to load contact info") + } + const data = await response.json() + setFormData((prev) => ({ + ...prev, + email: data.email || prev.email, + phone: data.phone || prev.phone, + })) + } catch (error) { + console.error("Failed to load contact info:", error) + setContactError("Unable to load contact information") + } finally { + setContactLoading(false) } + }, []) - loadUserData() - }, [api, setDiscordId, setLoadingState, setErrorState, updateFormData, updatePreferences, state.preferences.theme, state.preferences.compactMode, state.preferences.reducedMotion]) + const handleSaveContact = useCallback(async () => { + if (!formData.discordId) return + setContactSaving(true) + setContactMessage(null) + setContactError(null) + try { + const response = await fetch("/api/account/contact", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + discordId: formData.discordId, + phone: formData.phone, + }), + }) + if (!response.ok) { + const payload = await response.json() + throw new Error(payload.error || "Failed to save contact info") + } + setContactMessage("Contact information updated") + } catch (error) { + console.error("Failed to save contact info:", error) + setContactError(error instanceof Error ? error.message : "Failed to save contact information") + } finally { + setContactSaving(false) + } + }, [formData.discordId, formData.phone]) - const handleContactSave = useCallback(async () => { - if (!state.discordId) return - - setLoadingState('contactSaving', true) - setErrorState('contactError', null) - + const fetchProfileSettings = useCallback(async (discordId: string) => { + setProfileSettingsLoading(true) + setProfileSettingsError(null) try { - await api.updateContactInfo(state.discordId, { - email: state.formData.email, - phone: state.formData.phone + const response = await fetch(`/api/account/profile?discordId=${discordId}`, { + cache: "no-store", + }) + if (!response.ok) { + throw new Error("Failed to load profile settings") + } + const data = await response.json() + setProfileSettings({ + displayName: data.displayName ?? "", + headline: data.headline ?? "", + bio: data.bio ?? "", + location: data.location ?? "", + website: data.website ?? "", + handle: data.handle ?? "", }) - setMessageState('contactMessage', 'Contact information updated successfully') + setProfileMeta((prev) => ({ + ...prev, + displayName: data.displayName || prev.displayName, + username: data.username || prev.username, + avatarUrl: data.avatarUrl ?? prev.avatarUrl, + })) } catch (error) { - setErrorState('contactError', 'Failed to update contact information') + console.error("Failed to load profile settings:", error) + setProfileSettingsError("Unable to load profile settings") } finally { - setLoadingState('contactSaving', false) + setProfileSettingsLoading(false) } - }, [state.discordId, state.formData.email, state.formData.phone, api, setLoadingState, setErrorState, setMessageState]) - - const handleNotificationSave = useCallback(async () => { - if (!state.discordId) return - - setLoadingState('notificationSaving', true) - setErrorState('notificationError', null) - + }, []) + + const handleSaveProfileSettings = useCallback(async () => { + if (!formData.discordId) return + setProfileSettingsSaving(true) + setProfileSettingsError(null) + setProfileMessage(null) try { - await api.updateNotifications(state.discordId, state.formData.notifications) - setMessageState('notificationMessage', 'Notification settings updated successfully') + const response = await fetch("/api/account/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + discordId: formData.discordId, + ...profileSettings, + }), + }) + const payload = await response.json().catch(() => null) + if (!response.ok) { + throw new Error(payload?.error || "Failed to update profile") + } + setProfileSettings({ + displayName: payload.displayName ?? "", + headline: payload.headline ?? "", + bio: payload.bio ?? "", + location: payload.location ?? "", + website: payload.website ?? "", + handle: payload.handle ?? "", + }) + setProfileMeta((prev) => ({ + ...prev, + displayName: payload.displayName || prev.displayName, + username: payload.username || prev.username, + avatarUrl: payload.avatarUrl ?? prev.avatarUrl, + })) + setProfileMessage("Profile updated successfully.") } catch (error) { - setErrorState('notificationError', 'Failed to update notification settings') + console.error("Failed to update profile settings:", error) + setProfileSettingsError(error instanceof Error ? error.message : "Unable to update profile settings.") } finally { - setLoadingState('notificationSaving', false) + setProfileSettingsSaving(false) } - }, [state.discordId, state.formData.notifications, api, setLoadingState, setErrorState, setMessageState]) - - const handlePrivacySave = useCallback(async () => { - if (!state.discordId) return - - setLoadingState('privacySaving', true) - setErrorState('privacyError', null) - + }, [formData.discordId, profileSettings]) + + const fetchLinkedAccounts = useCallback(async (discordId: string) => { + setLinkedAccountsLoading(true) + setLinkedAccountsError(null) try { - await api.updatePrivacySettings(state.discordId, state.formData.privacy) - setMessageState('privacyMessage', 'Privacy settings updated successfully') + const response = await fetch(`/api/account/linked-accounts?discordId=${discordId}`, { + cache: "no-store", + }) + if (!response.ok) { + throw new Error("Failed to load linked accounts") + } + const data = await response.json() + setLinkedAccounts(Array.isArray(data.accounts) ? data.accounts : []) } catch (error) { - setErrorState('privacyError', 'Failed to update privacy settings') + console.error("Failed to load linked accounts:", error) + setLinkedAccountsError("Unable to load linked accounts") } finally { - setLoadingState('privacySaving', false) + setLinkedAccountsLoading(false) } - }, [state.discordId, state.formData.privacy, api, setLoadingState, setErrorState, setMessageState]) + }, []) - const handleDeleteAccount = useCallback(async () => { - if (!state.discordId) return - - if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) { - return + const handleAddLinkedAccount = useCallback( + async (event: React.FormEvent) => { + event.preventDefault() + if (!formData.discordId || !linkedAccountForm.handle.trim()) { + return + } + setLinkedAccountSaving(true) + setLinkedAccountsError(null) + try { + const providerConfig = + LINKED_ACCOUNT_PROVIDERS.find((provider) => provider.value === linkedAccountForm.provider) ?? + LINKED_ACCOUNT_PROVIDERS[0] + let normalizedHandle = linkedAccountForm.handle.trim() + if (providerConfig?.urlBased && normalizedHandle && !/^https?:\/\//i.test(normalizedHandle)) { + normalizedHandle = `https://${normalizedHandle}` + } + const response = await fetch("/api/account/linked-accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + discordId: formData.discordId, + provider: linkedAccountForm.provider, + handle: normalizedHandle, + metadata: { + urlBased: providerConfig?.urlBased ?? false, + label: providerConfig?.label, + }, + }), + }) + if (!response.ok) { + const payload = await response.json().catch(() => null) + throw new Error(payload?.error || "Failed to add linked account") + } + const data = await response.json() + setLinkedAccounts(Array.isArray(data.accounts) ? data.accounts : []) + setLinkedAccountForm((prev) => ({ ...prev, handle: "" })) + } catch (error) { + console.error("Failed to add linked account:", error) + setLinkedAccountsError(error instanceof Error ? error.message : "Unable to add linked account") + } finally { + setLinkedAccountSaving(false) + } + }, + [formData.discordId, linkedAccountForm.handle, linkedAccountForm.provider], + ) + + const handleRemoveLinkedAccount = useCallback( + async (accountId: string) => { + if (!formData.discordId) return + setLinkedAccountSaving(true) + setLinkedAccountsError(null) + try { + const response = await fetch("/api/account/linked-accounts", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ discordId: formData.discordId, accountId }), + }) + if (!response.ok) { + const payload = await response.json().catch(() => null) + throw new Error(payload?.error || "Failed to remove linked account") + } + const data = await response.json() + setLinkedAccounts(Array.isArray(data.accounts) ? data.accounts : []) + } catch (error) { + console.error("Failed to remove linked account:", error) + setLinkedAccountsError(error instanceof Error ? error.message : "Unable to remove linked account") + } finally { + setLinkedAccountSaving(false) + } + }, + [formData.discordId], + ) + + const handleTogglePrimaryDiscordId = useCallback(() => { + if (!formData.discordId) return + setShowPrimaryDiscordId((prev) => !prev) + }, [formData.discordId]) + + const handleCopyProfileLink = useCallback(() => { + if (!profileShareUrl) return + navigator.clipboard + ?.writeText(profileShareUrl) + .then(() => setProfileMessage("Profile link copied to clipboard")) + .catch(() => setProfileMessage("Unable to copy profile link at the moment.")) + }, [profileShareUrl]) + + const fetchSubscriptions = useCallback(async (discordId: string) => { + setSubscriptionsLoading(true) + setSubscriptionsError(null) + try { + const response = await fetch(`/api/subscriptions?userId=${discordId}`, { + cache: "no-store", + }) + if (!response.ok) { + throw new Error("Failed to load subscriptions") + } + const payload = await response.json() + setSubscriptions( + (payload?.subscriptions || []).map((sub: any) => ({ + id: sub.id, + name: sub.name || "Unknown Server", + tier: sub.tier, + status: sub.status, + currentPeriodStart: (sub.currentPeriodStart || sub.current_period_start)?.toString(), + currentPeriodEnd: (sub.currentPeriodEnd || sub.current_period_end)?.toString(), + pricePerMonth: Number(sub.pricePerMonth ?? sub.monthly_price ?? 0), + discordServerId: sub.discordServerId || sub.guild_id, + stripeCustomerId: sub.stripeCustomerId ?? sub.stripe_customer_id ?? null, + })), + ) + } catch (error) { + console.error("Failed to load subscriptions:", error) + setSubscriptions([]) + setSubscriptionsError("Unable to load subscriptions right now.") + } finally { + setSubscriptionsLoading(false) + } + }, []) + + useEffect(() => { + if (!formData.discordId) return + fetchLinkedAccounts(formData.discordId) + }, [fetchLinkedAccounts, formData.discordId]) + + const fetchPreferences = useCallback(async (discordId: string) => { + try { + const response = await fetch(`/api/preferences?discordId=${discordId}`, { + cache: "no-store", + }) + if (!response.ok) { + throw new Error("Failed to load preferences") + } + const data = await response.json() + setPreferences({ + ...PREFERENCE_DEFAULTS, + ...data, + }) + if (typeof window !== "undefined" && data?.preferredLanguage) { + try { + window.localStorage.setItem("preferred_language", data.preferredLanguage) + } catch (error) { + console.error("Failed to persist language preference locally:", error) + } + } + } catch (error) { + console.error("Failed to load preferences:", error) } - - setLoadingState('dataDeleting', true) - setErrorState('dataError', null) - + }, []) + + const handleLanguageChange = useCallback( + async (value: string) => { + if (!formData.discordId) return + setLanguageSaving(true) + setLanguageError(null) + try { + const response = await fetch("/api/preferences", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + discordId: formData.discordId, + preferredLanguage: value, + }), + }) + if (!response.ok) { + const payload = await response.json() + throw new Error(payload.error || "Failed to update language preference") + } + setPreferences((prev) => ({ ...prev, preferredLanguage: value })) + if (typeof window !== "undefined") { + try { + window.localStorage.setItem("preferred_language", value) + } catch (error) { + console.error("Failed to persist language preference locally:", error) + } + } + } catch (error) { + console.error("Failed to update language preference:", error) + setLanguageError("Failed to update language") + } finally { + setLanguageSaving(false) + } + }, + [formData.discordId], + ) + + const handleBillingPreferenceSave = useCallback(async () => { + if (!formData.discordId) return + setBillingSaving(true) + setBillingError(null) + setBillingMessage(null) try { - await api.deleteAccount(state.discordId) - router.push('/') + const response = await fetch("/api/preferences", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + discordId: formData.discordId, + fullName: preferences.fullName || "", + birthDate: preferences.birthDate || "", + addressCountry: preferences.addressCountry || "", + addressState: preferences.addressState || "", + addressCity: preferences.addressCity || "", + addressStreet: preferences.addressStreet || "", + addressHouseNumber: preferences.addressHouseNumber || "", + addressPostalCode: preferences.addressPostalCode || "", + }), + }) + if (!response.ok) { + const payload = await response.json() + throw new Error(payload.error || "Failed to save billing details") + } + setBillingMessage("Billing details saved.") } catch (error) { - setErrorState('dataError', 'Failed to delete account') + console.error("Failed to save billing details:", error) + setBillingError("Could not save billing details.") } finally { - setLoadingState('dataDeleting', false) + setBillingSaving(false) } - }, [state.discordId, api, setLoadingState, setErrorState, router]) - - const handleExportData = useCallback(async () => { - if (!state.discordId) return - - setLoadingState('dataDeleting', true) - setErrorState('dataError', null) - + }, [formData.discordId, preferences]) + + const fetchNotifications = useCallback(async (discordId: string) => { + setNotificationsLoading(true) + setNotificationsError(null) try { - const data = await api.exportData(state.discordId) - const url = URL.createObjectURL(data) - const a = document.createElement('a') - a.href = url - a.download = `vectobeat-data-${state.discordId}.pdf` - a.click() - URL.revokeObjectURL(url) - setMessageState('dataMessage', 'Data exported successfully') + const response = await fetch(`/api/account/notifications?discordId=${discordId}`, { + cache: "no-store", + }) + if (!response.ok) throw new Error("Failed to load notifications") + const data = await response.json() + setNotifications({ + ...NOTIFICATION_DEFAULTS, + ...data, + }) } catch (error) { - setErrorState('dataError', 'Failed to export data') + console.error("Failed to load notifications:", error) + setNotificationsError("Unable to load notifications") } finally { - setLoadingState('dataDeleting', false) + setNotificationsLoading(false) } - }, [state.discordId, api, setLoadingState, setErrorState, setMessageState]) - - const renderProfileTab = () => ( -
- - updateFormData({ email: value })} - error={state.contactError} - disabled={state.contactLoading} - /> - updateFormData({ phone: value })} - disabled={state.contactLoading} - /> -
- - Save Contact Info - -
-
- - - updateFormData({ language: value })} - disabled={state.languageSaving} - /> - updateFormData({ timezone: value })} - disabled={state.languageSaving} - /> -
- {/* Add preferences save logic */}} - loading={state.languageSaving} - > - Save Preferences - -
-
-
+ }, []) + + const handleNotificationToggle = useCallback( + async (key: keyof NotificationState, value: boolean) => { + if (!formData.discordId) return + setNotifications((prev) => ({ ...prev, [key]: value })) + setNotificationsSaving(true) + setNotificationsError(null) + try { + const response = await fetch("/api/account/notifications", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + discordId: formData.discordId, + [key]: value, + }), + }) + if (!response.ok) { + const payload = await response.json() + throw new Error(payload.error || "Failed to update notifications") + } + } catch (error) { + console.error("Failed to update notifications:", error) + setNotificationsError("Failed to save notification settings") + } finally { + setNotificationsSaving(false) + } + }, + [formData.discordId], ) - const renderNotificationsTab = () => ( - - - Save Notification Settings - - + const fetchPrivacy = useCallback(async (discordId: string) => { + setPrivacyLoading(true) + setPrivacyError(null) + try { + const response = await fetch(`/api/account/privacy?discordId=${discordId}`, { + cache: "no-store", + }) + if (!response.ok) throw new Error("Failed to load privacy settings") + const data = await response.json() + setPrivacy({ + ...PRIVACY_DEFAULTS, + ...data, + }) + } catch (error) { + console.error("Failed to load privacy settings:", error) + setPrivacyError("Unable to load privacy settings") + } finally { + setPrivacyLoading(false) + } + }, []) + + const handlePrivacyToggle = useCallback( + async (key: keyof PrivacyState, value: boolean) => { + if (!formData.discordId) return + setPrivacy((prev) => ({ ...prev, [key]: value })) + setPrivacySaving(true) + setPrivacyError(null) + try { + const response = await fetch("/api/account/privacy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + discordId: formData.discordId, + [key]: value, + }), + }) + if (!response.ok) { + const payload = await response.json() + throw new Error(payload.error || "Failed to update privacy settings") + } + } catch (error) { + console.error("Failed to update privacy settings:", error) + setPrivacyError("Failed to save privacy settings") + } finally { + setPrivacySaving(false) } - > - updateFormData({ - notifications: { ...state.formData.notifications, email: checked } - })} - /> - updateFormData({ - notifications: { ...state.formData.notifications, sms: checked } - })} - /> - updateFormData({ - notifications: { ...state.formData.notifications, push: checked } - })} - /> - updateFormData({ - notifications: { ...state.formData.notifications, marketing: checked } - })} - /> - updateFormData({ - notifications: { ...state.formData.notifications, updates: checked } - })} - /> - + }, + [formData.discordId], ) - const renderPrivacyTab = () => ( - - - Save Privacy Settings - - + const fetchSecurity = useCallback(async (discordId: string) => { + setSecurityLoading(true) + setSecurityError(null) + try { + const response = await fetch(`/api/account/security?discordId=${discordId}`, { + cache: "no-store", + }) + if (!response.ok) throw new Error("Failed to load security settings") + const data = await response.json() + setSecurity({ + ...SECURITY_DEFAULTS, + ...data, + }) + } catch (error) { + console.error("Failed to load security settings:", error) + setSecurityError("Unable to load security settings") + } finally { + setSecurityLoading(false) + } + }, []) + + const fetchSessions = useCallback(async (discordId: string, token?: string | null) => { + setSessionsLoading(true) + setSessionsError(null) + try { + const headers: Record = token ? { Authorization: `Bearer ${token}` } : {} + const response = await fetch(`/api/account/security/sessions?discordId=${discordId}`, { + headers, + cache: "no-store", + credentials: "include", + }) + if (!response.ok) { + throw new Error("Failed to load sessions") } - > - updateFormData({ - privacy: { ...state.formData.privacy, profileVisible: checked } - })} - /> - updateFormData({ - privacy: { ...state.formData.privacy, activityVisible: checked } - })} - /> - updateFormData({ - privacy: { ...state.formData.privacy, analyticsEnabled: checked } - })} - /> - + const data = await response.json() + const fetched: ActiveSessionState[] = Array.isArray(data.sessions) + ? data.sessions.map((session: ActiveSessionState & { id: string }) => ({ + ...session, + id: session.id, + })) + : [] + setSessions(fetched) + setCurrentSessionId((prev) => { + if (prev && fetched.some((session) => session.id === prev)) { + return prev + } + return fetched[0]?.id ?? prev + }) + } catch (error) { + console.error("Failed to load sessions:", error) + setSessionsError("Unable to load active sessions") + } finally { + setSessionsLoading(false) + } + }, []) + + const fetchBackupCodes = useCallback( + async (discordId: string, token?: string | null) => { + setBackupCodesLoading(true) + setBackupCodesError(null) + try { + const headers: Record = token ? { Authorization: `Bearer ${token}` } : {} + const response = await fetch(`/api/account/security/backup-codes?discordId=${discordId}`, { + headers, + cache: "no-store", + credentials: "include", + }) + if (!response.ok) { + const payload = await response.json() + throw new Error(payload.error || "Failed to load backup codes") + } + const data = await response.json() + setBackupCodes(Array.isArray(data.codes) ? data.codes : []) + setBackupCodesFetched(true) + } catch (error) { + console.error("Failed to load backup codes:", error) + setBackupCodesError("Unable to fetch backup codes") + } finally { + setBackupCodesLoading(false) + } + }, + [], ) - const renderSecurityTab = () => ( -
- - {/* Add 2FA toggle logic */}} - /> - {/* Add login alerts toggle logic */}} - /> - - - -
-
-

Export Data (Compliance Mode)

-

Download a consolidated PDF report of your account data for GDPR compliance

-
- - Request Data Export - -
-
-
-

Delete Account

-

Permanently delete your account and all associated data

-
- - Delete Account - -
-
-
-
-
+ const ensureTwoFactor = useCallback( + (sessionData: { id: string; requiresTwoFactor?: boolean; username?: string }) => { + if (!sessionData.requiresTwoFactor) { + return true + } + const key = `two_factor_verified_${sessionData.id}` + const timestamp = localStorage.getItem(key) + if (timestamp && Date.now() - Number(timestamp) < 1000 * 60 * 30) { + return true + } + appRouter.push(`/two-factor?context=login&username=${encodeURIComponent(sessionData.username || "VectoBeat")}`) + return false + }, + [appRouter], ) - const renderBillingTab = () => ( - -
- -

No billing information

-

Billing features are coming soon.

-
-
+ const handleSecurityUpdate = useCallback( + async (updates: Partial) => { + if (!formData.discordId) return + setSecurity((prev) => ({ ...prev, ...updates })) + setSecuritySaving(true) + setSecurityError(null) + try { + const response = await fetch("/api/account/security", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + discordId: formData.discordId, + ...updates, + lastPasswordChange: updates.lastPasswordChange ?? SECURITY_DEFAULTS.lastPasswordChange, + }), + }) + if (!response.ok) { + const payload = await response.json() + throw new Error(payload.error || "Failed to update security settings") + } + } catch (error) { + console.error("Failed to update security settings:", error) + setSecurityError("Failed to save security settings") + } finally { + setSecuritySaving(false) + } + }, + [formData.discordId], ) - const renderTabContent = () => { - switch (state.activeTab) { - case 'profile': - return renderProfileTab() - case 'notifications': - return renderNotificationsTab() - case 'privacy': - return renderPrivacyTab() - case 'security': - return renderSecurityTab() - case 'billing': - return renderBillingTab() - default: - return renderProfileTab() + const handleDownloadData = useCallback(async () => { + if (!formData.discordId) { + setPrivacyError("You must be signed in to download your data.") + return } - } + setDownloadingData(true) + setPrivacyError(null) + try { + const headers: Record = {} + if (authToken) { + headers.Authorization = `Bearer ${authToken}` + } + const response = await fetch(`/api/account/export?discordId=${formData.discordId}`, { + headers: Object.keys(headers).length ? headers : undefined, + credentials: "include", + }) + if (!response.ok) { + throw new Error("Failed to generate export") + } + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `vectobeat-data-${formData.discordId}.pdf` + anchor.click() + window.URL.revokeObjectURL(url) + } catch (error) { + console.error("Failed to download export:", error) + setPrivacyError("Unable to download your data export") + } finally { + setDownloadingData(false) + } + }, [formData.discordId, authToken]) + + const handleRevealBackupCodes = useCallback(() => { + if (!formData.discordId) { + setBackupCodesError("Unable to reveal backup codes without a valid session.") + return + } + setBackupCodesVisible((prev) => { + const next = !prev + if (next && !backupCodesFetched) { + fetchBackupCodes(formData.discordId, authToken) + } + return next + }) + }, [formData.discordId, authToken, backupCodesFetched, fetchBackupCodes]) + + const handleRegenerateBackupCodes = useCallback(async () => { + if (!formData.discordId) { + setBackupCodesError("Unable to regenerate backup codes right now.") + return + } + if (!window.confirm("Generate new backup codes? Existing codes will immediately stop working.")) { + return + } + setBackupCodesLoading(true) + setBackupCodesError(null) + try { + const headers: Record = { + "Content-Type": "application/json", + } + if (authToken) { + headers.Authorization = `Bearer ${authToken}` + } + const response = await fetch("/api/account/security/backup-codes", { + method: "POST", + headers, + credentials: "include", + body: JSON.stringify({ discordId: formData.discordId }), + }) + if (!response.ok) { + const payload = await response.json() + throw new Error(payload.error || "Failed to regenerate backup codes") + } + const data = await response.json() + setBackupCodes(Array.isArray(data.codes) ? data.codes : []) + setBackupCodesFetched(true) + setBackupCodesVisible(true) + fetchSecurity(formData.discordId) + } catch (error) { + console.error("Failed to regenerate backup codes:", error) + setBackupCodesError(error instanceof Error ? error.message : "Failed to regenerate backup codes") + } finally { + setBackupCodesLoading(false) + } + }, [formData.discordId, authToken, fetchSecurity]) + + const handleDownloadBackupCodes = useCallback(() => { + if (!backupCodes.length) return + const blob = new Blob([backupCodes.join("\n")], { type: "text/plain" }) + const url = window.URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = "vectobeat-backup-codes.txt" + anchor.click() + window.URL.revokeObjectURL(url) + }, [backupCodes]) + + useEffect(() => { + let cancelled = false + + const checkAuth = async () => { + try { + const urlParams = new URLSearchParams(window.location.search) + const tokenFromUrl = urlParams.get("token") + const userIdFromUrl = urlParams.get("user_id") + + const storedToken = localStorage.getItem("discord_token") || undefined + const storedUserId = localStorage.getItem("discord_user_id") || undefined + const token = tokenFromUrl || storedToken + const userId = userIdFromUrl || storedUserId + + if (tokenFromUrl && userIdFromUrl) { + localStorage.setItem("discord_token", tokenFromUrl) + localStorage.setItem("discord_user_id", userIdFromUrl) + window.history.replaceState({}, document.title, window.location.pathname) + } + + const response = await fetch("/api/verify-session", { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + credentials: "include", + }) + + if (!response.ok) { + throw new Error("Session verification request failed") + } + + const sessionData = await response.json() + + if (!sessionData?.authenticated) { + throw new Error("unauthenticated") + } + + if (!ensureTwoFactor(sessionData) || cancelled) { + return + } + + const resolvedUserId = sessionData.id || userId || "" + if (!resolvedUserId) { + throw new Error("Missing user identifier") + } - if (state.isLoading) { + setIsAuthorized(true) + setUserRole(sessionData.role || "member") + setAuthError(null) + if (token) { + localStorage.setItem("discord_token", token) + } + localStorage.setItem("discord_user_id", resolvedUserId) + setAuthToken(token ?? null) + setCurrentSessionId(sessionData.currentSessionId ?? null) + setFormData({ + email: sessionData.email ?? "", + username: sessionData.username ?? "", + phone: sessionData.phone ?? "", + discordId: resolvedUserId, + }) + setProfileMeta({ + username: sessionData.username ?? "", + displayName: sessionData.displayName || sessionData.username || "", + avatarUrl: sessionData.avatarUrl || null, + }) + const membershipList = Array.isArray(sessionData.membershipGuilds) + ? sessionData.membershipGuilds + : Array.isArray(sessionData.guilds) + ? sessionData.guilds + : [] + const adminList = Array.isArray(sessionData.adminGuilds) + ? sessionData.adminGuilds + : membershipList.filter((guild: any) => guild.isAdmin) + setGuildMetrics({ + membershipCount: sessionData.membershipCount ?? membershipList.length, + adminGuildCount: sessionData.adminGuildCount ?? adminList.length, + botGuildCount: sessionData.botGuildCount ?? adminList.filter((guild: any) => guild.hasBot).length, + }) + + void fetchContactInfo(resolvedUserId) + void fetchPreferences(resolvedUserId) + void fetchNotifications(resolvedUserId) + void fetchPrivacy(resolvedUserId) + void fetchSecurity(resolvedUserId) + void fetchSubscriptions(resolvedUserId) + void fetchProfileSettings(resolvedUserId) + } catch (error) { + console.error("Auth check failed:", error) + localStorage.removeItem("discord_token") + localStorage.removeItem("discord_user_id") + if (!cancelled) { + setIsAuthorized(false) + setAuthError("Please sign in with Discord again to load your account.") + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + void checkAuth() + + return () => { + cancelled = true + } + }, [ + ensureTwoFactor, + fetchContactInfo, + fetchPreferences, + fetchNotifications, + fetchPrivacy, + fetchSecurity, + fetchSubscriptions, + fetchProfileSettings, + ]) + + useEffect(() => { + if (formData.discordId) { + fetchSessions(formData.discordId, authToken) + } + }, [formData.discordId, authToken, fetchSessions]) + + const handleLogout = useCallback(() => { + localStorage.removeItem("discord_token") + localStorage.removeItem("discord_user_id") + setAuthToken(null) + setIsAuthorized(false) + appRouter.push("/") + }, [appRouter]) + + const handleSessionRevoke = useCallback( + async (sessionId: string) => { + if (!formData.discordId) { + setSessionsError("Missing session context.") + return + } + setSessionsError(null) + try { + const headers: Record = { + "Content-Type": "application/json", + } + if (authToken) { + headers.Authorization = `Bearer ${authToken}` + } + const response = await fetch("/api/account/security/sessions", { + method: "DELETE", + headers, + credentials: "include", + body: JSON.stringify({ discordId: formData.discordId, sessionId }), + }) + if (!response.ok) { + const payload = await response.json() + throw new Error(payload.error || "Failed to revoke session") + } + if (sessionId === currentSessionId) { + handleLogout() + return + } + fetchSessions(formData.discordId, authToken) + } catch (error) { + console.error("Failed to revoke session:", error) + setSessionsError(error instanceof Error ? error.message : "Unable to revoke session") + } + }, + [formData.discordId, authToken, currentSessionId, handleLogout, fetchSessions], + ) + + if (loading) { return ( -
+
-
+
+
+

Loading your account...

+
) } - return ( -
- - -
-
-

Account Settings

-

Manage your account preferences and settings

+ if (!isAuthorized) { + return ( +
+ +
+
+

Session Required

+

+ {authError || "Sign in with Discord to open your account settings."} +

+ + Sign in with Discord + +
+
+
+ ) + } -
- {/* Sidebar Navigation */} -
- +
+ + {/* Content Area */} +
+ {/* Profile Tab */} + {activeTab === "profile" && ( +
+
+
+
+ {profileMeta.avatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {profileMeta.displayName + ) : ( + (profileMeta.displayName || profileMeta.username || "VB") + .slice(0, 2) + .toUpperCase() + )} +
+
+

Display name

+

+ {profileMeta.displayName || profileMeta.username || "Community Member"} +

+

{formData.email || "No email attached"}

+
+
+
+ + + Contact support + +
+
+ + {profileMessage && ( +
+ {profileMessage} +
+ )} - {/* Main Content */} -
- {renderTabContent()} +
+
+

Profile details

+

+ Customize the information shown on your public profile at /profile/{profileSettings.handle || profileMeta.username || formData.discordId} +

+
+
+
+ + + setProfileSettings((prev) => ({ ...prev, displayName: e.target.value })) + } + disabled={profileSettingsLoading || profileSettingsSaving} + className="w-full px-4 py-2 rounded-lg bg-background border border-border/50 focus:border-primary/50 focus:outline-none transition-colors text-sm disabled:opacity-60" + placeholder={profileMeta.displayName || "Community Member"} + /> +
+
+ + + setProfileSettings((prev) => ({ ...prev, headline: e.target.value })) + } + disabled={profileSettingsLoading || profileSettingsSaving} + className="w-full px-4 py-2 rounded-lg bg-background border border-border/50 focus:border-primary/50 focus:outline-none transition-colors text-sm disabled:opacity-60" + placeholder="Automation lead @ OrbitLab" + /> +
+
+
+ +