import { Link } from 'react-router-dom'; import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import axios from '../../utils/axiosConfig'; import { getAvatarSrc } from '../../utils/avatarUtils'; import { useAuth } from '../../context/AuthContext'; import AppsManagement from '../../components/AppsManagement'; import '../primitives/primitives.css'; import './V2SettingsPage.css'; import V2BillingPanel from './V2BillingPanel'; import V2DevicesPanel from './V2DevicesPanel'; import V2DeleteAccount from './V2DeleteAccount'; import V2Avatar from './V2Avatar'; import V2AvatarCropDialog from './V2AvatarCropDialog'; import V2Segmented from './V2Segmented'; import { nextPaperAvatarPreset } from '../utils/avatarPresets'; import { LANGUAGE_REGISTRY } from '../../i18n'; import { V2_THEMES, V2ThemePref, setV2Theme } from '../utils/theme'; import useV2ThemePref from '../hooks/useV2ThemePref'; type TokenStatus = { hasToken?: boolean; createdAt?: string; scopes?: string[]; last4?: string | null; }; const formatTokenCreatedAt = (value: string) => { const date = new Date(value); if (Number.isNaN(date.getTime())) return null; const month = date.toLocaleDateString('en-US', { month: 'short' }); return `Created ${date.getDate()} ${month} ${date.getFullYear()}`; }; interface SettingsSectionProps { id: string; title: string; children: React.ReactNode; } // D215 (TASK-688): three groups on one screen, no jump nav. Language and theme // sit under Look; sign-out-everywhere and delete account fold into Account actions. // Custom "Developer apps" (AppsManagement) stays hidden until every path // (create, edit, delete, use) is walked live. Default off (TASK-674, D215). const developerAppsEnabled = () => process.env.REACT_APP_DEVELOPER_APPS === 'true'; // Admin-only: the instance's user list and usage analytics. Their routes // survived the Settings consolidation (#1544) but the way in did not — the // pages were reachable only by typing the URL (2026-09-08). const ADMIN_SECTION = { id: 'admin', title: 'Administration' } as const; const ADMIN_LINKS = [ { to: '/v2/admin/users', label: 'Users', detail: 'Registered users, bans, invitation codes' }, { to: '/v2/admin/analytics', label: 'Usage analytics', detail: 'Signups, messages, active users, activation funnel' }, ] as const; const settingsSectionId = (id: string) => `v2-settings-${id}`; const SettingsSection: React.FC = ({ id, title, children }) => (

{title}

{children}
); const Svg: React.FC<{ children: React.ReactNode }> = ({ children }) => ( ); const Pencil = () => ; const Chevron = () => ; const Key = () => ; const Plug = () => ; const Chat = () => ; const Card = () => ; const Globe = () => ; const Moon = () => ; const Next = () => ; const Warn = () => ; const V2AccountSection: React.FC = () => { const { currentUser, updateProfile, updateUsername } = useAuth(); const { t } = useTranslation(); const accountName = String(currentUser?.displayName || currentUser?.username || 'Your account'); const accountUsername = String(currentUser?.username || ''); const accountEmail = String(currentUser?.email || ''); const profilePicture = String(currentUser?.profilePicture || 'default'); const hasFaceOrPhoto = profilePicture.startsWith('bigsmile:') || (!profilePicture.startsWith('paper:') && Boolean(getAvatarSrc(profilePicture))); const [name, setName] = useState(accountName); const [username, setUsername] = useState(accountUsername); const [savedUsername, setSavedUsername] = useState(accountUsername); const [email, setEmail] = useState(accountEmail); const [busy, setBusy] = useState(false); const [editing, setEditing] = useState(false); const [usernameBusy, setUsernameBusy] = useState(false); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); const [avatarFile, setAvatarFile] = useState(null); const [regeneratingAvatar, setRegeneratingAvatar] = useState(false); const [savingPhoto, setSavingPhoto] = useState(false); const [photoError, setPhotoError] = useState(null); const fileInputRef = useRef(null); const uploadPhotoButtonRef = useRef(null); useEffect(() => { setName(accountName); setUsername(accountUsername); setSavedUsername(accountUsername); setEmail(accountEmail); }, [accountEmail, accountName, accountUsername]); const save = async (event: React.FormEvent) => { event.preventDefault(); const nextName = name.trim(); if (nextName === accountName) { setNotice('No account changes to save.'); setError(null); return; } setBusy(true); setNotice(null); setError(null); try { await updateProfile({ displayName: nextName }); setNotice('Account saved.'); } catch (requestError) { const message = (requestError as { response?: { data?: { error?: string; msg?: string } } })?.response?.data; setError(message?.error || message?.msg || 'Couldn’t save your account. Try again.'); } finally { setBusy(false); } }; const saveUsername = async () => { const nextUsername = username.trim().toLowerCase(); if (nextUsername === accountUsername.toLowerCase()) { setNotice('No username changes to save.'); setError(null); return; } setUsernameBusy(true); setNotice(null); setError(null); try { const response = updateUsername ? await updateUsername(nextUsername) : await axios.put('/api/users/username', { username: nextUsername }); const updatedUsername = String( (response as { username?: string; data?: { username?: string } })?.username || (response as { data?: { username?: string } })?.data?.username || nextUsername, ); setUsername(updatedUsername); setSavedUsername(updatedUsername); setNotice('Username saved.'); } catch (requestError) { const message = (requestError as { response?: { data?: { error?: string; msg?: string } } })?.response?.data; setError(message?.error || message?.msg || 'Couldn’t save your username. Try again.'); } finally { setUsernameBusy(false); } }; const handleUsernameKeyDown = (event: React.KeyboardEvent) => { if (event.key !== 'Enter') return; event.preventDefault(); if (!usernameBusy) void saveUsername(); }; const choosePhoto = () => fileInputRef.current?.click(); const regenerateAvatar = async () => { setRegeneratingAvatar(true); setPhotoError(null); setError(null); setNotice(null); try { const userId = String(currentUser?._id || currentUser?.id || ''); const profilePicture = nextPaperAvatarPreset(userId, currentUser?.profilePicture); await updateProfile({ profilePicture }); setNotice(t('settings.avatar.regenerated')); } catch { setPhotoError(t('settings.avatar.regenerateFailed')); } finally { setRegeneratingAvatar(false); } }; const selectPhoto = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; if (!file.type.startsWith('image/')) { setPhotoError(t('settings.avatar.imageTypeRequired')); setError(null); return; } setPhotoError(null); setError(null); setNotice(null); setAvatarFile(file); }; const saveCroppedPhoto = async (image: Blob) => { setSavingPhoto(true); setPhotoError(null); setError(null); setNotice(null); let uploaded = false; try { const formData = new FormData(); formData.append('image', image, 'avatar.png'); const upload = await axios.post<{ url?: string }>('/api/uploads', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); const profilePicture = upload.data?.url; if (!profilePicture?.startsWith('/api/uploads/')) throw new Error('Upload path was not returned'); uploaded = true; await updateProfile({ profilePicture }); setNotice(t('settings.avatar.photoSaved')); setAvatarFile(null); } catch { setPhotoError(t(uploaded ? 'settings.avatar.profileSaveFailed' : 'settings.avatar.uploadFailed')); } finally { setSavingPhoto(false); } }; return (
{name || accountName} @{savedUsername} · {currentUser?.role === 'admin' ? 'administrator' : 'member'}
Email {accountEmail || '—'}

{t(hasFaceOrPhoto ? 'settings.avatar.descriptionAfterChoice' : 'settings.avatar.description')}

{photoError && !avatarFile && (

{photoError}

)}
setEmail(event.target.value)} />
{error &&

{error}

} {notice &&

{notice}

}
{avatarFile && ( { setAvatarFile(null); setPhotoError(null); }} onSave={(image) => { void saveCroppedPhoto(image); }} /> )} ); }; const V2AccountActions: React.FC = () => { const { logout } = useAuth(); const { t } = useTranslation(); const [revokingSessions, setRevokingSessions] = useState(false); const [error, setError] = useState(null); const signOutEverywhere = async () => { if (!window.confirm(t('settings.sessions.confirm'))) return; setRevokingSessions(true); setError(null); try { await axios.post('/api/auth/sessions/revoke'); logout(); } catch { setError(t('settings.sessions.failed')); setRevokingSessions(false); } }; return (
Account actions
{error &&

{error}

}
); }; const V2ApiTokenSection: React.FC = () => { const [hasToken, setHasToken] = useState(false); const [token, setToken] = useState(null); const [createdAt, setCreatedAt] = useState(null); const [last4, setLast4] = useState(null); const [showToken, setShowToken] = useState(false); const [busy, setBusy] = useState<'generate' | 'revoke' | null>(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); useEffect(() => { let active = true; const load = async () => { try { const response = await axios.get('/api/auth/api-token'); if (!active) return; setHasToken(Boolean(response.data?.hasToken)); setToken(null); setCreatedAt(response.data?.createdAt || null); setLast4(response.data?.last4 || null); setShowToken(false); } catch { // A missing token is an expected state. The generate action remains available. } }; void load(); return () => { active = false; }; }, []); const generate = async () => { setBusy('generate'); setError(null); setNotice(null); try { const response = await axios.post<{ apiToken?: string; createdAt?: string }>('/api/auth/api-token/generate', {}); const generatedToken = response.data.apiToken || null; setHasToken(true); setToken(generatedToken); setCreatedAt(response.data.createdAt || null); setLast4(generatedToken ? generatedToken.slice(-4) : null); setShowToken(true); setNotice('New API token generated. Copy it now; treat it like a password.'); } catch { setError('Couldn’t generate an API token. Try again.'); } finally { setBusy(null); } }; const revoke = async () => { setBusy('revoke'); setError(null); setNotice(null); try { await axios.delete('/api/auth/api-token'); setHasToken(false); setToken(null); setCreatedAt(null); setLast4(null); setShowToken(false); setNotice('API token revoked.'); } catch { setError('Couldn’t revoke the API token. Try again.'); } finally { setBusy(null); } }; const copy = async () => { if (!token) return; try { await navigator.clipboard.writeText(token); setNotice('API token copied.'); } catch { setError('Couldn’t copy the API token. Select and copy it manually.'); } }; const maskedToken = '••••••••••••••••••••••••••••••••'; const createdLabel = createdAt ? formatTokenCreatedAt(createdAt) : null; const statusLabel = hasToken ? `Active${last4 ? ` · ends in ${last4}` : ''}` : 'None'; return ( <>
API token {statusLabel} {hasToken ? ( <> ) : ( )}
{(error || notice || token) && (
{error &&

{error}

} {notice &&

{notice}

} {token && ( <>
{token && showToken ? token : maskedToken}
{createdLabel &&

{createdLabel}

} )}
)} ); }; const V2LanguageSection: React.FC = () => { const { i18n, t } = useTranslation(); const active = i18n.resolvedLanguage || i18n.language || 'en'; const theme = useV2ThemePref(); const chooseTheme = (pref: V2ThemePref) => setV2Theme(pref); return ( <>
{t('common.language.label')} language.code === active) || LANGUAGE_REGISTRY[0]).code} onChange={(code) => { void i18n.changeLanguage(code); }} options={LANGUAGE_REGISTRY.map((language) => ({ value: language.code, label: language.short, ariaLabel: language.label }))} />
{t('common.theme.label')} ({ value: pref, label: t(`common.theme.${pref}`) }))} />
); }; const V2AdminSection: React.FC = () => (
{ADMIN_LINKS.map((link) => ( {link.label} {link.detail} ))}
); const V2SettingsPage: React.FC = () => { const { currentUser } = useAuth(); const isAdmin = currentUser?.role === 'admin'; return (

Settings

Connectors Manage connected services {developerAppsEnabled() && (
Developer apps
)}
{isAdmin && ( )}
); }; export default V2SettingsPage;