码桶
发现社区成员的开源项目
App.jsx121 KB
import React, { useState, useEffect, useRef, Suspense } from 'react';
import { Server, User, Shield, Key, LogOut, Plus, Trash2, RefreshCw, Zap, Languages, CheckCircle, AlertCircle, X, ChevronDown, Settings, Save, Fingerprint, Moon, Sun, Search, Upload, Globe, Layers, Keyboard, WifiOff, Activity, Menu, BarChart3, Database, FileText, ShieldAlert, Wifi, Eye, Code, ArrowRightLeft, BarChart2, Repeat, Mail, FileWarning, Copy } from 'lucide-react';
import useTranslate from './hooks/useTranslate.ts';
import { getAuthHeaders } from './utils/auth.ts';
import SecurityBadges from './components/SecurityBadges.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import { AuthProvider } from './contexts/AuthContext.jsx';
import { ToastProvider } from './contexts/ToastContext.jsx';
import { ThemeProvider } from './contexts/ThemeContext.jsx';
const Login = React.lazy(() => import('./components/Login.jsx'));
const ZoneDetail = React.lazy(() => import('./components/ZoneDetail.jsx'));
const AddAccountModal = React.lazy(() => import('./components/AddAccountModal.jsx'));
const AddSessionModal = React.lazy(() => import('./components/AddSessionModal.jsx'));
const ChangePasswordModal = React.lazy(() => import('./components/ChangePasswordModal.jsx'));
const PasskeyModal = React.lazy(() => import('./components/PasskeyModal.jsx'));
const TotpModal = React.lazy(() => import('./components/TotpModal.jsx'));
const BulkOperationsModal = React.lazy(() => import('./components/BulkOperationsModal.jsx'));
const UserManagement = React.lazy(() => import('./components/UserManagement.jsx'));
const Dashboard = React.lazy(() => import('./components/Dashboard.jsx'));
const OnboardingTour = React.lazy(() => import('./components/OnboardingTour.jsx'));
const MonitorsModal = React.lazy(() => import('./components/MonitorsModal.jsx'));
const App = () => {
const { t, lang, changeLang, toggleLang } = useTranslate();
const [auth, setAuth] = useState(null);
const [showAccountSelector, setShowAccountSelector] = useState(false);
const accountSelectorRef = useRef(null);
const [zones, setZones] = useState([]);
const [selectedZone, setSelectedZone] = useState(null);
const [loading, setLoading] = useState(false);
const [toasts, setToasts] = useState([]);
const toastIdCounter = useRef(0);
const [recoveryToken, setRecoveryToken] = useState('');
const [recoveryLoading, setRecoveryLoading] = useState(false);
const [recoveryError, setRecoveryError] = useState('');
const [storageToggleLoading, setStorageToggleLoading] = useState(false);
const [zoneStorageLoading, setZoneStorageLoading] = useState(false);
const [isLocalMode, setIsLocalMode] = useState(false);
const [darkMode, setDarkMode] = useState(() => {
const saved = localStorage.getItem('darkMode');
return saved ? saved === 'true' : false;
});
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState(null);
const [searchLoading, setSearchLoading] = useState(false);
const [searchResultsVisible, setSearchResultsVisible] = useState(50);
const searchRef = useRef(null);
const refreshInProgress = useRef(false);
const refreshAbortController = useRef(null);
const authRef = useRef(null);
const zoneFetchCache = useRef(new Map());
const [isOffline, setIsOffline] = useState(!navigator.onLine);
const [dnsRecordCounts, setDnsRecordCounts] = useState(() => {
try { return JSON.parse(localStorage.getItem('dns_record_counts') || '{}'); } catch { return {}; }
});
const [sidebarOpen, setSidebarOpen] = useState(false);
// Hash-based routing: /#/zone/{name}/{tab} or /#/overview
const parseHash = () => {
const hash = window.location.hash.replace(/^#\/?/, '');
const parts = hash.split('/');
if (parts[0] === 'zone' && parts[1]) {
return { zoneName: decodeURIComponent(parts[1]), tab: parts[2] || 'dns' };
}
return { zoneName: null, tab: parts[0] || 'overview' };
};
const initialRoute = parseHash();
const [activeTab, setActiveTab] = useState(initialRoute.tab || 'overview');
const pendingRouteRef = useRef(initialRoute.zoneName ? initialRoute : null);
const suppressHashUpdate = useRef(false);
const [zoneDropdownOpen, setZoneDropdownOpen] = useState(false);
const [zoneSearchFilter, setZoneSearchFilter] = useState('');
const zoneDropdownRef = useRef(null);
// Modal visibility toggles
const [showAddAccount, setShowAddAccount] = useState(false);
const [showAddSession, setShowAddSession] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
const [showPasskeyModal, setShowPasskeyModal] = useState(false);
const [showTotpModal, setShowTotpModal] = useState(false);
const [showBulkModal, setShowBulkModal] = useState(false);
const [showUserManagement, setShowUserManagement] = useState(false);
const [showShortcutsHelp, setShowShortcutsHelp] = useState(false);
const [showMonitorsModal, setShowMonitorsModal] = useState(false);
const [failedMonitorCount, setFailedMonitorCount] = useState(0);
const zoneDetailRef = useRef(null);
const searchInputRef = useRef(null);
useEffect(() => {
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
localStorage.setItem('darkMode', String(darkMode));
}, [darkMode]);
// Lock body scroll when mobile sidebar open
useEffect(() => {
if (sidebarOpen && window.innerWidth <= 768) {
document.body.classList.add('modal-open');
} else {
document.body.classList.remove('modal-open');
}
return () => document.body.classList.remove('modal-open');
}, [sidebarOpen]);
// Click-outside handler for zone dropdown
useEffect(() => {
if (!zoneDropdownOpen) return;
function handleClickOutside(e) {
if (zoneDropdownRef.current && !zoneDropdownRef.current.contains(e.target)) {
setZoneDropdownOpen(false);
setZoneSearchFilter('');
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [zoneDropdownOpen]);
const MAX_TOASTS = 3;
const dismissToast = (id) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 300);
};
const showToast = (message, type = 'success') => {
const id = ++toastIdCounter.current;
const toast = { message, type, id, createdAt: Date.now(), exiting: false };
setToasts(prev => {
const next = [toast, ...prev];
if (next.length > MAX_TOASTS) {
return next.slice(0, MAX_TOASTS);
}
return next;
});
setTimeout(() => dismissToast(id), 3000);
};
const persistAuth = (authData) => {
const storage = authData.remember ? localStorage : sessionStorage;
storage.setItem('auth_session', JSON.stringify(authData));
};
const tryRefreshToken = async (authData, signal) => {
try {
const res = await fetch('/api/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: authData.refreshToken }),
signal
});
if (res.ok) {
const data = await res.json();
const freshAccounts = (data.accounts && data.accounts.length > 0) ? data.accounts : null;
const sessions = (authData.sessions || []).map((s, i) =>
i === (authData.activeSessionIndex || 0)
? { ...s, token: data.token, ...(freshAccounts ? { accounts: freshAccounts } : {}) }
: s
);
return { ...authData, token: data.token, refreshToken: data.refreshToken || authData.refreshToken, sessions, ...(freshAccounts ? { accounts: freshAccounts } : {}) };
}
} catch (e) {
if (e.name !== 'AbortError') console.error('Token refresh failed:', e);
}
return null;
};
// Fetch wrapper that auto-injects auth headers and refreshes JWT on 401
const authFetch = async (url, options = {}) => {
const currentAuth = authRef.current;
if (!currentAuth) return fetch(url, options);
const withType = !!(options.body && !options.headers?.['Content-Type'] && typeof options.body === 'string');
const mergedHeaders = { ...getAuthHeaders(currentAuth, withType), ...options.headers };
const res = await fetch(url, { ...options, headers: mergedHeaders });
if (res.status === 401 && currentAuth.mode === 'server' && currentAuth.refreshToken) {
const refreshed = await tryRefreshToken(currentAuth);
if (refreshed) {
setAuth(refreshed);
persistAuth(refreshed);
const retryHeaders = { ...getAuthHeaders(refreshed, withType), ...options.headers };
return fetch(url, { ...options, headers: retryHeaders });
}
}
return res;
};
const handleLogout = () => {
if (auth && auth.mode === 'server' && auth.token) {
fetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${auth.token}` },
body: JSON.stringify({ refreshToken: auth.refreshToken })
}).catch(() => {});
}
if (refreshAbortController.current) {
refreshAbortController.current.abort();
refreshAbortController.current = null;
}
setAuth(null);
setZones([]);
setSelectedZone(null);
localStorage.removeItem('auth_session');
sessionStorage.removeItem('auth_session');
};
// Keep authRef in sync so timers/callbacks always see fresh auth
useEffect(() => { authRef.current = auth; }, [auth]);
// Sync URL hash when zone/tab changes
useEffect(() => {
if (suppressHashUpdate.current) { suppressHashUpdate.current = false; return; }
let newHash;
if (selectedZone) {
newHash = `#/zone/${encodeURIComponent(selectedZone.name)}/${activeTab}`;
} else {
newHash = activeTab && activeTab !== 'overview' ? `#/${activeTab}` : '#/';
}
if (window.location.hash !== newHash) {
window.history.pushState(null, '', newHash);
}
}, [selectedZone?.id, activeTab]);
// Handle browser back/forward
useEffect(() => {
const handlePopState = () => {
suppressHashUpdate.current = true;
const route = parseHash();
if (route.zoneName) {
const match = zones.find(z => z.name === route.zoneName);
if (match) {
if (!selectedZone || selectedZone.id !== match.id) selectZone(match, authRef.current || auth);
setActiveTab(route.tab || 'dns');
}
} else {
setActiveTab(route.tab || 'overview');
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [zones, selectedZone, auth]);
// Online/offline detection
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Sync isLocalMode from global preference
useEffect(() => {
if (auth && auth.mode === 'server') {
setIsLocalMode(localStorage.getItem('global_local_mode') === 'true');
} else {
setIsLocalMode(false);
}
}, [auth?.mode]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (accountSelectorRef.current && !accountSelectorRef.current.contains(event.target)) {
setShowAccountSelector(false);
}
if (searchRef.current && !searchRef.current.contains(event.target)) {
setSearchResults(null);
}
};
const handleScroll = () => {
if (showAccountSelector) setShowAccountSelector(false);
};
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, { capture: true });
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, { capture: true });
};
}, [showAccountSelector]);
// Global keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
const tag = e.target.tagName;
const isInputFocused = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable;
if (e.key === 'Escape') {
if (zoneDropdownOpen) { setZoneDropdownOpen(false); setZoneSearchFilter(''); return; }
if (sidebarOpen) { setSidebarOpen(false); return; }
if (showShortcutsHelp) { setShowShortcutsHelp(false); return; }
if (showMonitorsModal) { setShowMonitorsModal(false); return; }
if (showAddAccount) { setShowAddAccount(false); return; }
if (showAddSession) { setShowAddSession(false); return; }
if (showChangePassword) { setShowChangePassword(false); return; }
if (showPasskeyModal) { setShowPasskeyModal(false); return; }
if (showTotpModal) { setShowTotpModal(false); return; }
if (showBulkModal) { setShowBulkModal(false); return; }
if (showUserManagement) { setShowUserManagement(false); return; }
if (showAccountSelector) { setShowAccountSelector(false); return; }
if (searchResults !== null) { setSearchResults(null); return; }
return;
}
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
if (searchInputRef.current) searchInputRef.current.focus();
return;
}
if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
e.preventDefault();
if (selectedZone && zoneDetailRef.current) zoneDetailRef.current.openAddRecord();
return;
}
if (e.key === '?' && !isInputFocused) {
e.preventDefault();
setShowShortcutsHelp(prev => !prev);
return;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [showShortcutsHelp, showMonitorsModal, showAddAccount, showAddSession, showChangePassword, showPasskeyModal, showTotpModal, showBulkModal, showUserManagement, showAccountSelector, searchResults, selectedZone]);
// Periodic token refresh (every 12 minutes) for server mode
useEffect(() => {
if (!auth || auth.mode !== 'server' || !auth.refreshToken) return;
const REFRESH_INTERVAL = 12 * 60 * 1000; // 12 minutes
const abortController = new AbortController();
refreshAbortController.current = abortController;
const doRefresh = async () => {
if (refreshInProgress.current) return;
if (abortController.signal.aborted) return;
refreshInProgress.current = true;
try {
const currentAuth = authRef.current;
const updated = await tryRefreshToken(currentAuth, abortController.signal);
if (abortController.signal.aborted) return;
if (updated) {
setAuth(updated);
persistAuth(updated);
} else {
doLogout();
}
} finally {
refreshInProgress.current = false;
}
};
const intervalId = setInterval(doRefresh, REFRESH_INTERVAL);
return () => {
clearInterval(intervalId);
abortController.abort();
refreshAbortController.current = null;
};
}, [auth?.refreshToken, auth?.mode]);
// Fetch failed monitor count for badge (server mode only)
useEffect(() => {
if (!auth || auth.mode !== 'server') {
setFailedMonitorCount(0);
return;
}
const fetchMonitorCount = async () => {
try {
const res = await fetch('/api/monitors', {
headers: { 'Authorization': `Bearer ${auth.token}` }
});
if (res.ok) {
const data = await res.json();
const monitors = data.monitors || [];
setFailedMonitorCount(monitors.filter(m => m.lastStatus === 'fail').length);
}
} catch {
// ignore
}
};
fetchMonitorCount();
const interval = setInterval(fetchMonitorCount, 60000);
return () => clearInterval(interval);
}, [auth?.token, auth?.mode]);
const selectZone = (zone, authData) => {
const sessions = authData.sessions || [];
// In local mode, set _localToken on auth for the selected zone
if (zone._localKey) {
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
const newAuth = { ...authData, _localToken: localTokens[zone._localKey] || null };
setAuth(newAuth);
persistAuth(newAuth);
} else if (zone._sessionIdx != null && sessions[zone._sessionIdx]) {
const s = sessions[zone._sessionIdx];
const newAuth = {
...authData,
_localToken: null,
token: s.token,
username: s.username,
role: s.role,
currentAccountIndex: zone._accountIdx || 0,
activeSessionIndex: zone._sessionIdx
};
setAuth(newAuth);
persistAuth(newAuth);
}
setSelectedZone(zone);
};
const fetchZones = async (authData) => {
setLoading(true);
const globalLocal = localStorage.getItem('global_local_mode') === 'true';
const allZones = [];
const promises = [];
// Clear the deduplication cache at the start of each fetch cycle
const batchCache = zoneFetchCache.current;
batchCache.clear();
// Helper: fetch zones and extract result + authType from response
let got401 = false;
let gotError = null;
const fetchZonesApi = (headers) => fetch('/api/zones', { headers }).then(async res => {
if (res.ok) {
const data = await res.json();
return { zones: data.result || [], authType: data._authType || 'api_token' };
}
if (res.status === 401) got401 = true;
else if (!gotError) gotError = res.status;
return { zones: [], authType: 'api_token' };
}).catch((e) => {
if (!gotError) gotError = e.message || 'Network error';
return { zones: [], authType: 'api_token' };
});
if (globalLocal && authData.mode === 'server') {
// Local mode: only use tokens from localStorage
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
const keys = Object.keys(localTokens);
for (const key of keys) {
const token = localTokens[key];
const cacheKey = `local:${token}`;
if (!batchCache.has(cacheKey)) {
batchCache.set(cacheKey, fetchZonesApi({ 'X-Cloudflare-Token': token }));
}
promises.push(batchCache.get(cacheKey).then(({ zones, authType }) =>
zones.map(z => ({ ...z, _localKey: key, _owner: key, _accountType: authType, _accountName: key, _tokenHint: '…' + token.slice(-4) }))
));
}
} else {
// Server mode: use JWT + managed accounts
const sessions = authData.sessions || [{ token: authData.token, username: authData.username, role: authData.role, accounts: authData.accounts || [] }];
for (let si = 0; si < sessions.length; si++) {
const session = sessions[si];
const accounts = session.accounts || [];
const sessionPromises = [];
if (accounts.length === 0) {
const cacheKey = `session:${session.token}:0`;
if (!batchCache.has(cacheKey)) {
batchCache.set(cacheKey, fetchZonesApi({ 'Authorization': `Bearer ${session.token}`, 'X-Managed-Account-Index': '0' }));
}
sessionPromises.push(
batchCache.get(cacheKey).then(({ zones, authType }) =>
zones.map(z => ({ ...z, _sessionIdx: si, _accountIdx: 0, _owner: session.username, _accountType: authType, _accountName: authData.email || session.username || 'Default', _tokenHint: '' }))
)
);
} else {
for (const acc of accounts) {
const cacheKey = `session:${session.token}:${acc.id}`;
if (!batchCache.has(cacheKey)) {
batchCache.set(cacheKey, fetchZonesApi({ 'Authorization': `Bearer ${session.token}`, 'X-Managed-Account-Index': String(acc.id) }));
}
sessionPromises.push(
batchCache.get(cacheKey).then(({ zones, authType }) =>
zones.map(z => ({ ...z, _sessionIdx: si, _accountIdx: acc.id, _owner: session.username, _accountType: acc.type || authType, _accountName: acc.name || `Account ${acc.id}`, _tokenHint: acc.hint || '' }))
)
);
}
}
promises.push(Promise.all(sessionPromises).then(results => results.flat()));
}
}
const results = await Promise.all(promises);
for (const zones of results) allZones.push(...zones);
// If all server-mode fetches got 401 and no zones, session is expired — auto-logout
if (got401 && allZones.length === 0 && authData.mode === 'server') {
if (authData.refreshToken) {
const refreshed = await tryRefreshToken(authData);
if (refreshed) {
setAuth(refreshed);
persistAuth(refreshed);
setLoading(false);
return fetchZones(refreshed);
}
}
showToast(t('sessionExpired'), 'error');
setLoading(false);
doLogout();
return;
}
// Show toast for non-401 errors when no zones loaded
if (gotError && allZones.length === 0) {
showToast(typeof gotError === 'number' ? `${t('errorOccurred')} (HTTP ${gotError})` : gotError, 'error');
}
// Deduplicate zones by id (same zone might appear under different accounts)
const seen = new Set();
const uniqueZones = [];
for (const z of allZones) {
const key = `${z._owner}_${z.id}`;
if (!seen.has(key)) {
seen.add(key);
uniqueZones.push(z);
}
}
// Filter out hidden (unbound) zones
const hiddenZones = JSON.parse(localStorage.getItem('hidden_zones') || '[]');
const visibleZones = hiddenZones.length > 0 ? uniqueZones.filter(z => !hiddenZones.includes(z.id)) : uniqueZones;
const sortedZones = visibleZones.sort((a, b) => new Date(b.modified_on) - new Date(a.modified_on));
setZones(sortedZones);
// Fetch DNS record counts from server (fire-and-forget)
if (sortedZones.length > 0 && authData.mode === 'server') {
authFetch('/api/dns-counts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ zoneIds: sortedZones.map(z => z.id) })
}).then(r => r.json()).then(data => {
if (data.success && data.counts) {
setDnsRecordCounts(prev => {
const merged = { ...prev, ...data.counts };
try { localStorage.setItem('dns_record_counts', JSON.stringify(merged)); } catch {}
return merged;
});
}
}).catch(() => {});
}
if (sortedZones.length > 0) {
// Check for pending route from URL hash
const pending = pendingRouteRef.current;
if (pending && pending.zoneName) {
const match = sortedZones.find(z => z.name === pending.zoneName);
pendingRouteRef.current = null;
if (match) {
selectZone(match, authData);
setActiveTab(pending.tab || 'dns');
} else {
selectZone(sortedZones[0], authData);
}
} else if (selectedZone) {
const stillExists = sortedZones.find(z => z.id === selectedZone.id && z._owner === selectedZone._owner);
if (stillExists) {
selectZone(stillExists, authData);
} else {
selectZone(sortedZones[0], authData);
}
} else {
// No pending route and no selected zone — check current hash
const route = parseHash();
if (route.zoneName) {
const match = sortedZones.find(z => z.name === route.zoneName);
if (match) {
selectZone(match, authData);
setActiveTab(route.tab || 'dns');
} else {
selectZone(sortedZones[0], authData);
}
} else {
selectZone(sortedZones[0], authData);
}
}
} else {
setSelectedZone(null);
}
setLoading(false);
};
useEffect(() => {
const saved = localStorage.getItem('auth_session') || sessionStorage.getItem('auth_session');
if (saved) {
try {
const credentials = JSON.parse(saved);
// If we have a refresh token, refresh immediately to get a fresh access token
if (credentials.refreshToken && credentials.mode === 'server') {
tryRefreshToken(credentials).then(async (updated) => {
if (updated) {
setAuth(updated);
persistAuth(updated);
fetchZones(updated);
} else {
// Refresh failed — try with existing token (may still work)
setAuth(credentials);
fetchZones(credentials);
}
});
} else {
setAuth(credentials);
fetchZones(credentials);
}
} catch (err) {
console.error('Failed to parse saved auth session:', err);
localStorage.removeItem('auth_session');
sessionStorage.removeItem('auth_session');
}
}
}, []);
const handleLogin = (credentials) => {
const session = { token: credentials.token, username: credentials.username, role: credentials.role, accounts: credentials.accounts || [] };
const newAuth = {
...credentials,
refreshToken: credentials.refreshToken || null,
sessions: [session],
activeSessionIndex: 0
};
setAuth(newAuth);
const storage = credentials.remember ? localStorage : sessionStorage;
storage.setItem('auth_session', JSON.stringify(newAuth));
fetchZones(newAuth);
};
// Wrap the context handleLogout to also clear local zone state
const doLogout = () => {
handleLogout();
setZones([]);
setSelectedZone(null);
};
const handleRecoveryLogin = async () => {
if (!recoveryToken.trim()) return;
setRecoveryLoading(true);
setRecoveryError('');
try {
const res = await fetch('/api/verify-token', {
headers: { 'X-Cloudflare-Token': recoveryToken }
});
const data = await res.json();
if (res.ok && data.success) {
const credentials = { mode: 'client', token: recoveryToken, remember: false };
handleLogin(credentials);
setRecoveryToken('');
} else {
let errMsg = data.message || t('loginFailed');
if (errMsg === 'Invalid token') errMsg = t('invalidToken');
if (errMsg === 'No token provided') errMsg = t('tokenRequired');
if (errMsg === 'Failed to verify token') errMsg = t('verifyFailed');
setRecoveryError(errMsg);
}
} catch (err) {
setRecoveryError(t('errorOccurred'));
} finally {
setRecoveryLoading(false);
}
};
const handleSaveTokenToServer = async () => {
if (!recoveryToken.trim()) return;
setRecoveryLoading(true);
setRecoveryError('');
try {
const res = await fetch('/api/admin/settings', {
method: 'POST',
headers: { ...getAuthHeaders(auth, true) },
body: JSON.stringify({ token: recoveryToken, accountIndex: 0 })
});
const data = await res.json();
if (res.ok && data.success) {
showToast(t('tokenSaved'), 'success');
setRecoveryToken('');
fetchZones(auth);
} else {
setRecoveryError(data.error || t('tokenSaveFailed'));
}
} catch (err) {
setRecoveryError(t('tokenSaveFailed'));
} finally {
setRecoveryLoading(false);
}
};
const handleRemoveAccount = async (accountId) => {
if (!confirm(t('confirmRemoveAccount'))) return;
const adminHeaders = { 'Authorization': `Bearer ${auth.token}`, 'Content-Type': 'application/json' };
try {
const res = await fetch(`/api/admin/settings?index=${accountId}`, {
method: 'DELETE',
headers: adminHeaders
});
if (res.ok) {
showToast(t('accountRemoved'), 'success');
const accRes = await fetch('/api/admin/settings', { headers: adminHeaders });
const accData = await accRes.json();
if (accRes.ok) {
const newAccounts = accData.accounts || [];
const si = auth.activeSessionIndex || 0;
const newSessions = [...(auth.sessions || [])];
if (newSessions[si]) newSessions[si] = { ...newSessions[si], accounts: newAccounts };
const newAuth = { ...auth, accounts: newAccounts, sessions: newSessions, currentAccountIndex: newAccounts.length > 0 ? newAccounts[0].id : 0 };
setAuth(newAuth);
persistAuth(newAuth);
fetchZones(newAuth);
}
}
} catch (err) { console.error('Failed to remove account:', err); showToast(t('errorOccurred'), 'error'); }
};
const refreshAuthAccounts = async (authData) => {
const si = authData.activeSessionIndex || 0;
const adminHeaders = { 'Authorization': `Bearer ${authData.token}` };
const accRes = await fetch('/api/admin/settings', { headers: adminHeaders });
if (accRes.ok) {
const accData = await accRes.json();
const updatedAccounts = accData.accounts || [];
const newSessions = [...(authData.sessions || [])];
if (newSessions[si]) newSessions[si] = { ...newSessions[si], accounts: updatedAccounts };
const newAuth = { ...authData, _localToken: null, accounts: updatedAccounts, sessions: newSessions };
setAuth(newAuth);
persistAuth(newAuth);
return newAuth;
}
return authData;
};
const ensureFreshAuth = async () => {
if (auth.refreshToken) {
const updated = await tryRefreshToken(auth);
if (updated) {
setAuth(updated);
persistAuth(updated);
return updated;
}
}
return auth;
};
const handleToggleStorage = () => {
if (auth.mode !== 'server') return;
const newLocal = !isLocalMode;
localStorage.setItem('global_local_mode', newLocal ? 'true' : 'false');
setIsLocalMode(newLocal);
setSelectedZone(null);
showToast(newLocal ? t('switchedToLocal') : t('switchedToServer'), 'success');
fetchZones(auth);
};
const handleAddLocalToken = async () => {
if (!recoveryToken.trim()) return;
setRecoveryLoading(true);
setRecoveryError('');
try {
const res = await fetch('/api/verify-token', { headers: { 'X-Cloudflare-Token': recoveryToken } });
const data = await res.json();
if (res.ok && data.success) {
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
let idx = 0;
while (localTokens[`local_${idx}`]) idx++;
localTokens[`local_${idx}`] = recoveryToken;
localStorage.setItem('local_cf_tokens', JSON.stringify(localTokens));
showToast(t('localTokenAdded'), 'success');
setRecoveryToken('');
fetchZones(auth);
} else {
let errMsg = data.message || t('loginFailed');
if (errMsg === 'Invalid token') errMsg = t('invalidToken');
if (errMsg === 'No token provided') errMsg = t('tokenRequired');
if (errMsg === 'Failed to verify token') errMsg = t('verifyFailed');
setRecoveryError(errMsg);
}
} catch (err) {
console.error('Failed to add local token:', err);
setRecoveryError(t('errorOccurred'));
} finally {
setRecoveryLoading(false);
}
};
const handleRemoveLocalToken = (key) => {
if (!confirm(t('confirmRemoveLocalToken'))) return;
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
delete localTokens[key];
localStorage.setItem('local_cf_tokens', JSON.stringify(localTokens));
showToast(t('localTokenRemoved'), 'success');
if (selectedZone && selectedZone._localKey === key) {
setSelectedZone(null);
}
fetchZones(auth);
};
const getLocalTokensList = () => {
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
return Object.entries(localTokens).map(([key, token]) => ({
key,
token,
masked: token.substring(0, 8) + '••••••••' + token.substring(token.length - 4),
zones: zones.filter(z => z._localKey === key).map(z => z.name)
}));
};
const handleUploadLocalToken = async (key) => {
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
const token = localTokens[key];
if (!token) return;
setStorageToggleLoading(true);
try {
const freshAuth = await ensureFreshAuth();
const maxId = (freshAuth.accounts || []).reduce((max, a) => Math.max(max, a.id), -1);
const nextIndex = maxId + 1;
const adminHeaders = { 'Authorization': `Bearer ${freshAuth.token}`, 'Content-Type': 'application/json' };
const res = await fetch('/api/admin/settings', {
method: 'POST', headers: adminHeaders,
body: JSON.stringify({ token, accountIndex: nextIndex })
});
if (res.ok) {
delete localTokens[key];
localStorage.setItem('local_cf_tokens', JSON.stringify(localTokens));
if (selectedZone && selectedZone._localKey === key) setSelectedZone(null);
showToast(t('uploadedToServer'), 'success');
const newAuth = await refreshAuthAccounts(freshAuth);
fetchZones(newAuth);
} else {
showToast(t('tokenSaveFailed'), 'error');
}
} catch (err) {
console.error('Failed to upload local token to server:', err);
showToast(t('tokenSaveFailed'), 'error');
}
setStorageToggleLoading(false);
};
const [confirmGroupDelete, setConfirmGroupDelete] = useState(null);
const handleUnbindZone = (zoneId) => {
const hidden = JSON.parse(localStorage.getItem('hidden_zones') || '[]');
if (!hidden.includes(zoneId)) {
hidden.push(zoneId);
localStorage.setItem('hidden_zones', JSON.stringify(hidden));
}
// Remove from current zones list and deselect
const remaining = zones.filter(z => z.id !== zoneId);
setZones(remaining);
if (selectedZone?.id === zoneId) {
setSelectedZone(remaining.length > 0 ? remaining[0] : null);
if (remaining.length > 0) selectZone(remaining[0], auth);
}
showToast(t('zoneUnbound'), 'success');
};
const handleUnbindZoneFromDropdown = (e, zoneId) => {
e.stopPropagation();
if (!confirm(t('unbindZoneConfirmShort'))) return;
handleUnbindZone(zoneId);
};
const handleDeleteGroup = async (group) => {
setConfirmGroupDelete(null);
setZoneDropdownOpen(false);
if (group.localKey) {
// Local token: remove from localStorage
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
delete localTokens[group.localKey];
localStorage.setItem('local_cf_tokens', JSON.stringify(localTokens));
if (selectedZone && selectedZone._localKey === group.localKey) setSelectedZone(null);
showToast(t('localTokenRemoved'), 'success');
fetchZones(auth);
} else if (group.sessionIdx != null && group.accountIdx != null) {
// Server account: delete from server
try {
const freshAuth = await ensureFreshAuth();
const adminHeaders = { 'Authorization': `Bearer ${freshAuth.token}`, 'Content-Type': 'application/json' };
const res = await fetch(`/api/admin/settings?index=${group.accountIdx}`, {
method: 'DELETE', headers: adminHeaders
});
if (res.ok) {
showToast(t('accountRemoved'), 'success');
const newAuth = await refreshAuthAccounts(freshAuth);
if (selectedZone && selectedZone._sessionIdx === group.sessionIdx && selectedZone._accountIdx === group.accountIdx) {
setSelectedZone(null);
}
fetchZones(newAuth);
} else {
showToast(t('errorOccurred'), 'error');
}
} catch (err) {
console.error('Failed to delete group:', err);
showToast(t('errorOccurred'), 'error');
}
}
};
const handleToggleZoneStorage = async (zoneObj) => {
if (auth.mode !== 'server') return;
setZoneStorageLoading(true);
try {
const freshAuth = await ensureFreshAuth();
if (zoneObj._localKey) {
// Local → Server: upload this token to server
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
const token = localTokens[zoneObj._localKey];
if (token) {
const maxId = (freshAuth.accounts || []).reduce((max, a) => Math.max(max, a.id), -1);
const nextIndex = maxId + 1;
const adminHeaders = { 'Authorization': `Bearer ${freshAuth.token}`, 'Content-Type': 'application/json' };
const res = await fetch('/api/admin/settings', {
method: 'POST', headers: adminHeaders,
body: JSON.stringify({ token, accountIndex: nextIndex })
});
if (res.ok) {
delete localTokens[zoneObj._localKey];
localStorage.setItem('local_cf_tokens', JSON.stringify(localTokens));
showToast(t('uploadedToServer'), 'success');
const newAuth = await refreshAuthAccounts(freshAuth);
setSelectedZone(null);
fetchZones(newAuth);
} else {
showToast(t('tokenSaveFailed'), 'error');
}
}
} else {
// Server → Local: retrieve token from server, save locally, delete from server
const idx = zoneObj._accountIdx || 0;
const adminHeaders = { 'Authorization': `Bearer ${freshAuth.token}`, 'Content-Type': 'application/json' };
const res = await fetch(`/api/admin/settings?retrieve=${idx}`, { headers: adminHeaders });
const data = await res.json();
if (res.ok && data.token) {
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
let localIdx = 0;
while (localTokens[`local_${localIdx}`]) localIdx++;
localTokens[`local_${localIdx}`] = data.token;
localStorage.setItem('local_cf_tokens', JSON.stringify(localTokens));
await fetch(`/api/admin/settings?index=${idx}`, { method: 'DELETE', headers: adminHeaders });
showToast(t('switchedToLocal'), 'success');
const newAuth = await refreshAuthAccounts(freshAuth);
setSelectedZone(null);
fetchZones(newAuth);
} else {
showToast(t('tokenSaveFailed'), 'error');
}
}
} catch (err) {
console.error('Failed to toggle zone storage:', err);
showToast(t('tokenSaveFailed'), 'error');
}
setZoneStorageLoading(false);
};
const handleRemoveSession = (sessionIdx) => {
const sessions = auth.sessions || [];
if (sessions.length <= 1) {
doLogout();
return;
}
const newSessions = sessions.filter((_, i) => i !== sessionIdx);
const newAuth = {
...auth,
sessions: newSessions,
token: newSessions[0].token,
username: newSessions[0].username,
role: newSessions[0].role,
activeSessionIndex: 0,
currentAccountIndex: newSessions[0].accounts?.[0]?.id || 0
};
setAuth(newAuth);
persistAuth(newAuth);
setSelectedZone(null);
fetchZones(newAuth);
};
const handleSearch = async () => {
if (!searchQuery || searchQuery.trim().length < 2) return;
setSearchLoading(true);
setSearchResults(null);
setSearchResultsVisible(50);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(searchQuery.trim())}`, {
headers: getAuthHeaders(auth)
});
if (res.ok) {
const data = await res.json();
setSearchResults(data.results || []);
} else {
setSearchResults([]);
}
} catch (err) {
console.error('Search request failed:', err);
setSearchResults([]);
}
setSearchLoading(false);
};
const handleAccountAdded = (newAuth) => {
setAuth(newAuth);
persistAuth(newAuth);
fetchZones(newAuth);
};
const handleSessionAdded = (newAuth) => {
setAuth(newAuth);
persistAuth(newAuth);
fetchZones(newAuth);
};
if (!auth) {
return (
<Suspense fallback={<div className="lazy-loading" />}>
<Login onLogin={handleLogin} t={t} lang={lang} onLangChange={changeLang} />
</Suspense>
);
}
return (
<AuthProvider auth={auth} setAuth={setAuth}>
<ToastProvider showToast={showToast}>
<ThemeProvider t={t} lang={lang} changeLang={changeLang} toggleLang={toggleLang} darkMode={darkMode} setDarkMode={setDarkMode}>
<Suspense fallback={<div className="lazy-loading" />}>
<div className="fade-in">
{isOffline && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 10000,
background: 'linear-gradient(90deg, #f59e0b, #d97706)',
color: '#fff',
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
fontSize: '0.85rem',
fontWeight: 600,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}>
<WifiOff size={16} />
<span>{t('offlineMessage')}</span>
</div>
)}
{/* Toast Stack */}
{toasts.length > 0 && (
<div className="toast-stack" aria-live="polite" role="status">
{toasts.map((toastItem) => (
<div key={toastItem.id} className={`toast-item ${toastItem.exiting ? 'toast-exit' : 'toast-enter'}`} style={{
color: toastItem.type === 'success' ? 'var(--text)' : 'var(--error)',
}}>
{toastItem.type === 'success' ? <CheckCircle size={18} color="var(--success)" /> : <AlertCircle size={18} color="var(--error)" />}
<span style={{ fontSize: '0.875rem', fontWeight: '600', whiteSpace: 'nowrap', flex: 1 }}>{toastItem.message}</span>
<button onClick={() => dismissToast(toastItem.id)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px', display: 'flex', marginLeft: '4px', flexShrink: 0 }} aria-label="Close notification">
<X size={14} color="var(--text-muted)" />
</button>
<div className="toast-progress" style={{ animationDuration: '3s' }} />
</div>
))}
</div>
)}
{/* Keyboard Shortcuts Help Modal */}
{showShortcutsHelp && (
<div style={{
position: 'fixed', inset: 0, zIndex: 10000,
background: 'var(--modal-overlay)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn 0.2s ease-out'
}} onClick={() => setShowShortcutsHelp(false)}>
<div className="glass-card fade-in" style={{
width: '380px', maxWidth: '90vw', padding: '1.5rem',
position: 'relative'
}} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.25rem' }}>
<h3 style={{ fontSize: '1rem', margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<Keyboard size={18} color="var(--primary)" />
{t('keyboardShortcuts')}
</h3>
<button onClick={() => setShowShortcutsHelp(false)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px', display: 'flex' }} aria-label="Close">
<X size={16} color="var(--text-muted)" />
</button>
</div>
<p style={{ fontSize: '0.7rem', color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '0.75rem' }}>{t('shortcutGeneral')}</p>
{[
{ keys: ['Ctrl', 'K'], altKeys: ['\u2318', 'K'], label: t('shortcutFocusSearch') },
{ keys: ['Ctrl', 'N'], altKeys: ['\u2318', 'N'], label: t('shortcutNewRecord') },
{ keys: ['Esc'], label: t('shortcutCloseModal') },
{ keys: ['?'], label: t('shortcutShowHelp') },
].map((shortcut, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0.5rem 0', borderBottom: i < 3 ? '1px solid var(--border)' : 'none'
}}>
<span style={{ fontSize: '0.8rem', color: 'var(--text)' }}>{shortcut.label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{shortcut.keys.map((key, ki) => (
<React.Fragment key={ki}>
{ki > 0 && <span style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>+</span>}
<kbd style={{
display: 'inline-block', padding: '2px 6px',
fontSize: '0.7rem', fontFamily: 'inherit', fontWeight: 600,
background: 'var(--hover-bg)', border: '1px solid var(--border)',
borderRadius: '4px', color: 'var(--text-muted)',
minWidth: '22px', textAlign: 'center',
boxShadow: '0 1px 0 var(--border)'
}}>{key}</kbd>
</React.Fragment>
))}
{shortcut.altKeys && (
<>
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', margin: '0 2px' }}>{t('shortcutOr')}</span>
{shortcut.altKeys.map((key, ki) => (
<React.Fragment key={ki}>
{ki > 0 && <span style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>+</span>}
<kbd style={{
display: 'inline-block', padding: '2px 6px',
fontSize: '0.7rem', fontFamily: 'inherit', fontWeight: 600,
background: 'var(--hover-bg)', border: '1px solid var(--border)',
borderRadius: '4px', color: 'var(--text-muted)',
minWidth: '22px', textAlign: 'center',
boxShadow: '0 1px 0 var(--border)'
}}>{key}</kbd>
</React.Fragment>
))}
</>
)}
</div>
</div>
))}
</div>
</div>
)}
<header>
<button className="sidebar-hamburger" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label={t('toggleSidebar')}>
<Menu size={20} />
</button>
<button className="unstyled logo" onClick={() => window.location.reload()} aria-label="DNS Manager - Reload">
<Zap size={22} color="var(--primary)" />
DNS <span>Manager</span>
</button>
<div className="header-right" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{/* Search — desktop only */}
{auth.mode === 'server' && (
<div ref={searchRef} className="header-search-box" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', background: 'var(--hover-bg)', borderRadius: '8px', padding: '2px 8px', border: '1px solid var(--border)' }}>
<Search size={14} color="var(--text-muted)" />
<input
ref={searchInputRef}
type="text"
placeholder={t('globalSearchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
style={{
border: 'none', background: 'transparent', outline: 'none',
fontSize: '0.75rem', padding: '4px 2px', width: '120px',
color: 'var(--text)'
}}
/>
<button
onClick={handleSearch}
disabled={searchLoading || searchQuery.trim().length < 2}
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
padding: '2px', display: 'flex', color: 'var(--primary)',
opacity: searchQuery.trim().length < 2 ? 0.4 : 1
}}
aria-label={t('globalSearchPlaceholder')}
>
{searchLoading ? <RefreshCw className="spin" size={13} /> : <Search size={13} />}
</button>
</div>
{searchResults !== null && (
<div className="glass-card fade-in" style={{
position: 'absolute', top: '110%', right: 0, width: '360px',
maxHeight: '320px', overflowY: 'auto', zIndex: 150,
padding: '0.5rem', boxShadow: '0 10px 25px rgba(0,0,0,0.15)'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.25rem 0.5rem', marginBottom: '0.25rem' }}>
<span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', fontWeight: 600 }}>
{searchResults.length > 0
? t('searchResultCount').replace('{count}', searchResults.length)
: t('searchNoResults')}
</span>
<button onClick={() => setSearchResults(null)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '2px', display: 'flex' }} aria-label="Close search results">
<X size={14} color="var(--text-muted)" />
</button>
</div>
{searchResults.length === 0 && (
<p style={{ textAlign: 'center', padding: '1rem 0', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{t('searchNoResults')}
</p>
)}
{searchResults.slice(0, searchResultsVisible).map((result, idx) => (
<div
key={idx}
role="button"
tabIndex={0}
onClick={() => {
const matchedZone = zones.find(z => z.name === result.zoneName || z.id === result.zoneId);
if (matchedZone) selectZone(matchedZone, auth);
setSearchResults(null);
setSearchQuery('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const matchedZone = zones.find(z => z.name === result.zoneName || z.id === result.zoneId);
if (matchedZone) selectZone(matchedZone, auth);
setSearchResults(null);
setSearchQuery('');
}
}}
style={{
padding: '0.5rem 0.6rem', borderRadius: '6px', cursor: 'pointer',
marginBottom: '2px', transition: 'background 0.15s'
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '2px' }}>
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text)' }}>{result.zoneName}</span>
<span className="badge badge-blue" style={{ fontSize: '0.6rem', padding: '1px 5px' }}>
{result.type}
</span>
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)' }}>
{result.name} → {result.content}
</div>
</div>
))}
{searchResults.length > searchResultsVisible && (
<button
onClick={(e) => { e.stopPropagation(); setSearchResultsVisible(prev => prev + 50); }}
style={{
width: '100%', padding: '0.4rem', border: 'none',
background: 'var(--hover-bg)', borderRadius: '6px',
cursor: 'pointer', fontSize: '0.7rem', color: 'var(--primary)',
fontWeight: 600, marginTop: '0.25rem', transition: 'background 0.15s'
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--select-active-bg)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'var(--hover-bg)'}
>
{t('showMore') || 'Show more'} ({searchResults.length - searchResultsVisible} remaining)
</button>
)}
</div>
)}
</div>
)}
{/* Monitors — keep visible (has alert badge) */}
{auth.mode === 'server' && (
<button
onClick={() => setShowMonitorsModal(true)}
style={{ border: 'none', background: 'transparent', padding: '8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '2px', color: 'var(--text-muted)', borderRadius: '8px', transition: 'all 0.2s', position: 'relative' }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--hover-btn-bg)'; e.currentTarget.style.color = 'var(--primary)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)'; }}
title={t('monitors')}
aria-label={t('monitors')}
>
<Activity size={18} />
{failedMonitorCount > 0 && (
<span style={{
position: 'absolute', top: '2px', right: '0px',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.55rem', fontWeight: 700, minWidth: '14px', height: '14px',
padding: '0 3px', borderRadius: '7px',
background: 'var(--error)', color: '#fff',
lineHeight: 1
}}>
{failedMonitorCount}
</span>
)}
</button>
)}
{/* Dark mode toggle */}
<button
onClick={() => setDarkMode(!darkMode)}
style={{ border: 'none', background: 'transparent', padding: '8px', cursor: 'pointer', display: 'flex', color: 'var(--text-muted)', borderRadius: '8px', transition: 'all 0.2s' }}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--hover-btn-bg)';
e.currentTarget.style.color = 'var(--primary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--text-muted)';
}}
title={darkMode ? t('lightMode') : t('darkMode')}
aria-label={darkMode ? t('lightMode') : t('darkMode')}
>
{darkMode ? <Sun size={18} /> : <Moon size={18} />}
</button>
{/* Account menu — contains all other actions */}
<div style={{ position: 'relative' }} ref={accountSelectorRef}>
<button
onClick={() => setShowAccountSelector(!showAccountSelector)}
style={{ border: 'none', background: showAccountSelector ? 'var(--hover-btn-bg)' : 'transparent', padding: '6px 10px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px', color: 'var(--text-muted)', borderRadius: '8px', transition: 'background 0.2s', fontSize: '0.8rem' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--hover-btn-bg)'}
onMouseLeave={(e) => { if (!showAccountSelector) e.currentTarget.style.background = 'transparent'; }}
title={t('switchAccount')}
aria-label={t('switchAccount')}
aria-expanded={showAccountSelector}
aria-haspopup="true"
>
<User size={16} />
<span className="hide-mobile" style={{ maxWidth: '80px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{auth.username || 'admin'}</span>
<ChevronDown size={14} style={{ transition: 'transform 0.2s', transform: showAccountSelector ? 'rotate(180deg)' : 'none' }} />
</button>
{showAccountSelector && (
<div className="glass-card fade-in" role="menu" aria-label={t('switchAccount')} style={{
position: 'absolute',
top: '120%',
right: 0,
width: '240px',
padding: '0.25rem',
zIndex: 100,
maxHeight: '80vh',
overflowY: 'auto'
}}>
{/* Sessions */}
{auth.mode === 'server' && (auth.sessions || []).map((session, si) => (
<div key={session.username} style={{
padding: '0.5rem 0.75rem',
borderRadius: '6px',
fontSize: '0.8125rem',
display: 'flex',
alignItems: 'center',
gap: '8px',
color: si === (auth.activeSessionIndex || 0) ? 'var(--primary)' : 'var(--text)',
background: si === (auth.activeSessionIndex || 0) ? 'var(--select-active-bg)' : 'transparent',
fontWeight: si === (auth.activeSessionIndex || 0) ? 600 : 400,
marginBottom: '2px'
}}
onMouseEnter={e => { if (si !== (auth.activeSessionIndex || 0)) e.currentTarget.style.background = 'var(--hover-bg)'; }}
onMouseLeave={e => { if (si !== (auth.activeSessionIndex || 0)) e.currentTarget.style.background = 'transparent'; }}
>
<User size={14} />
<span style={{ flex: 1 }}>{session.username}</span>
<span className={`badge ${session.role === 'admin' ? 'badge-orange' : 'badge-green'}`} style={{ fontSize: '0.6rem', padding: '1px 5px' }}>
{session.role === 'admin' ? t('roleAdmin') : t('roleUser')}
</span>
{(auth.sessions || []).length > 1 && (
<X size={13} style={{ cursor: 'pointer', color: 'var(--text-muted)', flexShrink: 0 }}
onClick={(e) => { e.stopPropagation(); setShowAccountSelector(false); handleRemoveSession(si); }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
/>
)}
</div>
))}
{auth.mode === 'server' && (
<>
<div style={{ height: '1px', background: 'var(--border)', margin: '0.25rem 0' }}></div>
<button
className="unstyled"
role="menuitem"
onClick={() => { setShowAccountSelector(false); setShowAddSession(true); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--primary)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--select-active-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Plus size={14} />
{t('loginAnotherAccount')}
</button>
</>
)}
<div style={{ height: '1px', background: 'var(--border)', margin: '0.25rem 0' }}></div>
{/* Tools & Settings — moved from header */}
<button
className="unstyled"
role="menuitem"
onClick={() => { setShowAccountSelector(false); toggleLang(); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Languages size={14} />
{{ zh: 'English', en: '日本語', ja: '한국어', ko: '中文' }[lang]}
<span style={{ marginLeft: 'auto', fontSize: '0.7rem', color: 'var(--text-muted)', fontWeight: 600 }}>{lang.toUpperCase()}</span>
</button>
<button
className="unstyled"
role="menuitem"
onClick={() => { setShowAccountSelector(false); setShowShortcutsHelp(true); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Keyboard size={14} />
{t('keyboardShortcuts')}
</button>
{auth.mode === 'server' && (
<button
className="unstyled"
role="menuitem"
onClick={() => { setShowAccountSelector(false); handleToggleStorage(); }}
disabled={storageToggleLoading}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: isLocalMode ? 'var(--primary)' : 'var(--text)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
{storageToggleLoading ? <RefreshCw className="spin" size={14} /> : <Server size={14} />}
{isLocalMode ? t('storageLocal') : t('storageServer')}
<span style={{ marginLeft: 'auto', fontSize: '0.6rem', padding: '1px 6px', borderRadius: '9999px', background: isLocalMode ? 'var(--select-active-bg)' : 'var(--hover-bg)', color: isLocalMode ? 'var(--primary)' : 'var(--text-muted)', fontWeight: 600 }}>
{isLocalMode ? 'LOCAL' : 'SERVER'}
</span>
</button>
)}
{auth.mode === 'server' && !isLocalMode && (
<button
className="unstyled"
role="menuitem"
onClick={() => { setShowAccountSelector(false); setShowBulkModal(true); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Layers size={14} />
{t('bulkOperations')}
</button>
)}
{auth.role === 'admin' && (
<button
className="unstyled"
role="menuitem"
onClick={() => { setShowAccountSelector(false); setShowUserManagement(true); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Settings size={14} />
{t('usersManagement')}
</button>
)}
<div style={{ height: '1px', background: 'var(--border)', margin: '0.25rem 0' }}></div>
{/* Security */}
{auth.mode === 'server' && auth.username !== 'admin' && (
<button
className="unstyled"
role="menuitem"
onClick={() => { setShowAccountSelector(false); setShowChangePassword(true); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Key size={14} />
{t('changePassword')}
</button>
)}
{auth.mode === 'server' && window.PublicKeyCredential && (
<button
className="unstyled"
role="menuitem"
onClick={() => { setShowAccountSelector(false); setShowPasskeyModal(true); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Fingerprint size={14} />
{t('passkeyManage')}
</button>
)}
{auth.mode === 'server' && (
<button
className="unstyled"
role="menuitem"
onClick={() => { setShowAccountSelector(false); setShowTotpModal(true); }}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--hover-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<Shield size={14} />
{t('totpManage')}
</button>
)}
<div style={{ height: '1px', background: 'var(--border)', margin: '0.25rem 0' }}></div>
<button
className="unstyled"
role="menuitem"
onClick={doLogout}
style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', borderRadius: '6px', fontSize: '0.8125rem', display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--error)', width: '100%' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--error-bg)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<LogOut size={14} />
{t('logout')}
</button>
</div>
)}
</div>
</div>
</header>
{/* Extracted Modal Components */}
<ErrorBoundary t={t}>
<AddAccountModal show={showAddAccount} onClose={() => setShowAddAccount(false)} auth={auth} t={t} showToast={showToast} onAccountAdded={handleAccountAdded} />
<AddSessionModal show={showAddSession} onClose={() => setShowAddSession(false)} auth={auth} t={t} showToast={showToast} onSessionAdded={handleSessionAdded} />
<ChangePasswordModal show={showChangePassword} onClose={() => setShowChangePassword(false)} auth={auth} t={t} showToast={showToast} />
<PasskeyModal show={showPasskeyModal} onClose={() => setShowPasskeyModal(false)} auth={auth} t={t} showToast={showToast} />
<TotpModal show={showTotpModal} onClose={() => setShowTotpModal(false)} auth={auth} t={t} showToast={showToast} />
<BulkOperationsModal show={showBulkModal} onClose={() => setShowBulkModal(false)} auth={auth} authFetch={authFetch} t={t} showToast={showToast} zones={zones} />
<MonitorsModal show={showMonitorsModal} onClose={() => setShowMonitorsModal(false)} zones={zones} />
<UserManagement show={showUserManagement} onClose={() => setShowUserManagement(false)} auth={auth} t={t} showToast={showToast} />
</ErrorBoundary>
<a href="#main-content" className="skip-link">{t('skipToContent') || 'Skip to content'}</a>
<div className="app-layout" id="main-content">
{/* Sidebar */}
<aside className={`sidebar${sidebarOpen ? ' sidebar--open' : ''}`}>
<div className="sidebar-header">
<span className="sidebar-header-title">{t('yourDomains')}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<button
className="sidebar-close"
style={{ display: 'flex', alignItems: 'center', padding: '4px', border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-muted)', borderRadius: '6px' }}
onClick={() => fetchZones(auth)}
title={t('refresh')}
>
<RefreshCw size={14} className={loading ? 'spin' : ''} />
</button>
<button className="sidebar-close" onClick={() => setSidebarOpen(false)} aria-label={t('toggleSidebar')}>
<X size={16} />
</button>
</div>
</div>
{/* Custom zone dropdown */}
<div className="sidebar-zone-selector" ref={zoneDropdownRef}>
<button
className={`zone-dropdown${zoneDropdownOpen ? ' open' : ''}`}
onClick={() => { setZoneDropdownOpen(!zoneDropdownOpen); setZoneSearchFilter(''); }}
>
<span className="zone-dropdown-dot" style={{ background: selectedZone ? 'var(--success)' : 'var(--text-muted)' }} />
<span className="zone-dropdown-name">{selectedZone ? selectedZone.name : t('selectZone')}</span>
<ChevronDown size={14} className="zone-dropdown-chevron" />
</button>
{zoneDropdownOpen && (
<div className="zone-dropdown-menu">
{zones.length > 5 && (
<div className="zone-dropdown-search">
<div style={{ position: 'relative' }}>
<Search size={12} style={{ position: 'absolute', left: '8px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input
type="text"
placeholder={t('searchZones')}
value={zoneSearchFilter}
onChange={(e) => setZoneSearchFilter(e.target.value)}
autoFocus
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)}
{(() => {
const filtered = zones.filter(z => !zoneSearchFilter || z.name.toLowerCase().includes(zoneSearchFilter.toLowerCase()));
// Group zones by account credential
const groups = [];
const groupMap = new Map();
for (const z of filtered) {
const gk = `${z._sessionIdx ?? 'L'}_${z._accountIdx ?? z._localKey ?? 0}`;
if (!groupMap.has(gk)) {
// Resolve type from current auth state (safety net for stale zone data)
let resolvedType = z._accountType;
if (z._sessionIdx != null && auth.sessions?.[z._sessionIdx]?.accounts) {
const acc = auth.sessions[z._sessionIdx].accounts.find(a => a.id === z._accountIdx);
if (acc?.type) resolvedType = acc.type;
}
const group = { key: gk, type: resolvedType, name: z._accountName, tokenHint: z._tokenHint, sessionIdx: z._sessionIdx, accountIdx: z._accountIdx, localKey: z._localKey, zones: [] };
groupMap.set(gk, group);
groups.push(group);
}
groupMap.get(gk).zones.push(z);
}
const needsGrouping = groups.length > 1;
return groups.map((group, gi) => (
<React.Fragment key={group.key}>
<div style={{
padding: '0.35rem 0.75rem', fontSize: '0.65rem', fontWeight: 700,
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em',
display: 'flex', alignItems: 'center', gap: '6px',
borderTop: gi > 0 ? '1px solid var(--border)' : 'none',
marginTop: gi > 0 ? '0.25rem' : 0,
paddingTop: gi > 0 ? '0.5rem' : '0.35rem'
}}>
<span style={{
padding: '1px 5px', borderRadius: '3px', fontSize: '0.6rem', fontWeight: 700,
background: group.type === 'global_key' ? 'rgba(139, 92, 246, 0.12)' : 'rgba(59, 130, 246, 0.12)',
color: group.type === 'global_key' ? '#7c3aed' : '#2563eb'
}}>
{group.type === 'global_key' ? 'GK' : 'AT'}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, minWidth: 0 }}>{group.name}</span>
{group.tokenHint && (
<span style={{ fontFamily: 'monospace', fontSize: '0.6rem', opacity: 0.5, flexShrink: 0 }}>{group.tokenHint}</span>
)}
<span style={{ opacity: 0.5, flexShrink: 0 }}>{group.zones.length}</span>
<button
onClick={(e) => {
e.stopPropagation();
(async () => {
try {
if (group.localKey) {
const localTokens = JSON.parse(localStorage.getItem('local_cf_tokens') || '{}');
const entry = localTokens[group.localKey];
const val = typeof entry === 'string' ? entry : (entry?.token || entry?.key || '');
if (val) { await navigator.clipboard.writeText(val); showToast(t('tokenCopied')); }
else showToast(t('errorOccurred'), 'error');
} else {
const session = auth.sessions?.[group.sessionIdx];
if (!session) return;
const res = await fetch(`/api/admin/settings?retrieve=${group.accountIdx}`, { headers: { 'Authorization': `Bearer ${session.token}` } });
const data = await res.json();
if (data.token) { await navigator.clipboard.writeText(data.token); showToast(t('tokenCopied')); }
else showToast(data.error || t('errorOccurred'), 'error');
}
} catch { showToast(t('errorOccurred'), 'error'); }
})();
}}
style={{ border: 'none', background: 'none', cursor: 'pointer', padding: '2px', color: 'var(--text-muted)', borderRadius: '3px', display: 'flex', alignItems: 'center', flexShrink: 0 }}
title={t('copyToken')}
onMouseEnter={e => e.currentTarget.style.color = 'var(--primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>
<Copy size={11} />
</button>
<button
onClick={(e) => { e.stopPropagation(); setConfirmGroupDelete(group); }}
style={{ border: 'none', background: 'none', cursor: 'pointer', padding: '2px', color: 'var(--text-muted)', borderRadius: '3px', display: 'flex', alignItems: 'center', flexShrink: 0 }}
title={t('deleteGroup')}
onMouseEnter={e => e.currentTarget.style.color = 'var(--danger)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>
<Trash2 size={11} />
</button>
</div>
{group.zones.map(z => (
<div
key={`${z._owner}_${z.id}`}
className={`zone-dropdown-item${selectedZone?.id === z.id ? ' selected' : ''}`}
style={{ display: 'flex', alignItems: 'center' }}
>
<button
style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1, minWidth: 0, border: 'none', background: 'none', cursor: 'pointer', padding: '0', color: 'inherit', font: 'inherit', textAlign: 'left' }}
onClick={() => {
selectZone(z, auth);
setZoneDropdownOpen(false);
setZoneSearchFilter('');
if (activeTab === 'overview') setActiveTab('dns');
}}
>
<span className="zone-dropdown-dot" style={{ background: 'var(--success)' }} />
<span className="zone-dropdown-item-name">{z.name}</span>
{dnsRecordCounts[z.id] != null && (
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', marginLeft: 'auto', flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>{dnsRecordCounts[z.id]}</span>
)}
</button>
<button
onClick={(e) => handleUnbindZoneFromDropdown(e, z.id)}
style={{ border: 'none', background: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--text-muted)', borderRadius: '3px', display: 'flex', alignItems: 'center', flexShrink: 0, marginLeft: 'auto' }}
title={t('unbindZone')}
onMouseEnter={e => e.currentTarget.style.color = 'var(--danger)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>
<X size={12} />
</button>
</div>
))}
</React.Fragment>
));
})()}
</div>
)}
</div>
{/* Navigation */}
<nav className="sidebar-nav" onKeyDown={(e) => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
e.preventDefault();
const items = Array.from(e.currentTarget.querySelectorAll('.sidebar-nav-item'));
const idx = items.indexOf(document.activeElement);
let next;
if (e.key === 'ArrowDown') next = items[(idx + 1) % items.length];
else if (e.key === 'ArrowUp') next = items[(idx - 1 + items.length) % items.length];
else if (e.key === 'Home') next = items[0];
else if (e.key === 'End') next = items[items.length - 1];
if (next) next.focus();
}}>
<button
className={`sidebar-nav-item${activeTab === 'overview' ? ' active' : ''}`}
onClick={() => { setActiveTab('overview'); setSidebarOpen(false); }}
>
<BarChart3 size={16} /> {t('overview')}
</button>
{selectedZone && (
<>
<div className="sidebar-separator" />
<button
className={`sidebar-nav-item${activeTab === 'dns' ? ' active' : ''}`}
onClick={() => { setActiveTab('dns'); setSidebarOpen(false); }}
>
<Globe size={16} /> {t('dnsRecords')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'saas' ? ' active' : ''}`}
onClick={() => { setActiveTab('saas'); setSidebarOpen(false); }}
>
<Layers size={16} /> {t('saasHostnames')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'dnssettings' ? ' active' : ''}`}
onClick={() => { setActiveTab('dnssettings'); setSidebarOpen(false); }}
>
<Settings size={16} /> {t('dnsSettingsTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'dnsanalytics' ? ' active' : ''}`}
onClick={() => { setActiveTab('dnsanalytics'); setSidebarOpen(false); }}
>
<BarChart2 size={16} /> {t('dnsAnalyticsTab')}
</button>
{/* Performance */}
<div className="sidebar-separator" />
<span className="sidebar-section-label">{t('sidebarPerformance') || 'Performance'}</span>
<button
className={`sidebar-nav-item${activeTab === 'cache' ? ' active' : ''}`}
onClick={() => { setActiveTab('cache'); setSidebarOpen(false); }}
>
<Database size={16} /> {t('cacheTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'speed' ? ' active' : ''}`}
onClick={() => { setActiveTab('speed'); setSidebarOpen(false); }}
>
<Zap size={16} /> {t('speedTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'network' ? ' active' : ''}`}
onClick={() => { setActiveTab('network'); setSidebarOpen(false); }}
>
<Wifi size={16} /> {t('networkTab')}
</button>
{/* Security */}
<div className="sidebar-separator" />
<span className="sidebar-section-label">{t('sidebarSecurity') || 'Security'}</span>
<button
className={`sidebar-nav-item${activeTab === 'ssl' ? ' active' : ''}`}
onClick={() => { setActiveTab('ssl'); setSidebarOpen(false); }}
>
<Shield size={16} /> {t('sslTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'security' ? ' active' : ''}`}
onClick={() => { setActiveTab('security'); setSidebarOpen(false); }}
>
<ShieldAlert size={16} /> {t('securityTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'scrapeshield' ? ' active' : ''}`}
onClick={() => { setActiveTab('scrapeshield'); setSidebarOpen(false); }}
>
<Eye size={16} /> {t('scrapeShieldTab')}
</button>
{/* Configuration */}
<div className="sidebar-separator" />
<span className="sidebar-section-label">{t('sidebarConfig') || 'Configuration'}</span>
<button
className={`sidebar-nav-item${activeTab === 'pagerules' ? ' active' : ''}`}
onClick={() => { setActiveTab('pagerules'); setSidebarOpen(false); }}
>
<FileText size={16} /> {t('pageRulesTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'workers' ? ' active' : ''}`}
onClick={() => { setActiveTab('workers'); setSidebarOpen(false); }}
>
<Code size={16} /> {t('workersTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'rules' ? ' active' : ''}`}
onClick={() => { setActiveTab('rules'); setSidebarOpen(false); }}
>
<ArrowRightLeft size={16} /> {t('rulesTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'transform' ? ' active' : ''}`}
onClick={() => { setActiveTab('transform'); setSidebarOpen(false); }}
>
<Repeat size={16} /> {t('transformTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'origin' ? ' active' : ''}`}
onClick={() => { setActiveTab('origin'); setSidebarOpen(false); }}
>
<Server size={16} /> {t('originTab')}
</button>
{/* Email & Pages */}
<div className="sidebar-separator" />
<span className="sidebar-section-label">{t('sidebarEmail') || 'Email & Pages'}</span>
<button
className={`sidebar-nav-item${activeTab === 'email' ? ' active' : ''}`}
onClick={() => { setActiveTab('email'); setSidebarOpen(false); }}
>
<Mail size={16} /> {t('emailTab')}
</button>
<button
className={`sidebar-nav-item${activeTab === 'custompages' ? ' active' : ''}`}
onClick={() => { setActiveTab('custompages'); setSidebarOpen(false); }}
>
<FileWarning size={16} /> {t('customPagesTab')}
</button>
{/* Monitoring */}
<div className="sidebar-separator" />
<span className="sidebar-section-label">{t('sidebarMonitoring') || 'Monitoring'}</span>
<button
className={`sidebar-nav-item${activeTab === 'analytics' ? ' active' : ''}`}
onClick={() => { setActiveTab('analytics'); setSidebarOpen(false); }}
>
<BarChart2 size={16} /> {t('analyticsTab')}
</button>
</>
)}
</nav>
<div className="sidebar-footer">
{auth.mode === 'server' && (
<button className="sidebar-footer-btn" onClick={() => { setSidebarOpen(false); setShowAddAccount(true); }}>
<Plus size={14} />
{t('addNewToken')}
</button>
)}
{auth.mode === 'server' && (
<button className="sidebar-footer-btn muted" onClick={() => { setSidebarOpen(false); setShowAddSession(true); }}>
<User size={14} />
{t('loginAnotherAccount')}
</button>
)}
<a
href="https://github.com/C-NF/cloudflare-dns-manager"
target="_blank"
rel="noopener noreferrer"
className="sidebar-footer-btn muted"
style={{ textDecoration: 'none', justifyContent: 'center', fontSize: '0.65rem', opacity: 0.5, marginTop: '0.25rem' }}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
</div>
</aside>
<div className={`sidebar-backdrop${sidebarOpen ? ' sidebar-backdrop--visible' : ''}`} onClick={() => setSidebarOpen(false)} />
<div className="app-content">
<main style={{ paddingBottom: '3rem' }}>
{loading && zones.length === 0 ? (
<div className="container" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem', marginTop: '4rem' }}>
<RefreshCw className="spin" size={32} style={{ color: 'var(--primary)' }} />
<p style={{ color: 'var(--text-muted)' }}>{t('statusInitializing')}</p>
</div>
) : zones.length === 0 ? (
/* === NO ZONES === */
<div className="container" style={{ textAlign: 'center', marginTop: '4rem', color: 'var(--text-muted)' }}>
<div className="glass-card fade-in" style={{ maxWidth: '480px', margin: '0 auto', padding: '2rem', textAlign: 'center' }}>
<div style={{ display: 'inline-flex', padding: '0.75rem', background: 'var(--error-bg)', borderRadius: '12px', marginBottom: '1rem' }}>
<AlertCircle size={32} color="var(--error)" />
</div>
<h3 style={{ fontSize: '1.125rem', marginBottom: '0.5rem' }}>{t('noZonesFound')}</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '1.5rem', lineHeight: '1.5' }}>
{auth.mode === 'server' ? t('noZonesServerExplanation') : t('noZonesClientExplanation')}
</p>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'center', marginBottom: '1.5rem' }}>
<button className="btn btn-outline" onClick={doLogout} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<LogOut size={14} /> {t('backToLogin')}
</button>
<button className="btn btn-outline" onClick={() => fetchZones(auth)} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<RefreshCw size={14} /> {t('retryFetch')}
</button>
</div>
<div style={{ height: '1px', background: 'var(--border)', margin: '1.5rem 0' }}></div>
<p style={{ fontSize: '0.8rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>{t('noZonesEnterToken')}</p>
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>
{t('noZonesGetToken')}{' '}
<a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--primary)' }}>
dash.cloudflare.com/profile/api-tokens
</a>
</p>
<div style={{ position: 'relative', marginBottom: '0.75rem' }}>
<Shield size={16} style={{ position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input
type="password"
placeholder={t('tokenPlaceholder')}
value={recoveryToken}
onChange={(e) => { setRecoveryToken(e.target.value); setRecoveryError(''); }}
onKeyDown={(e) => { if (e.key === 'Enter' && auth.mode === 'server') handleSaveTokenToServer(); else if (e.key === 'Enter') handleRecoveryLogin(); }}
style={{ paddingLeft: '38px', width: '100%' }}
/>
</div>
{auth.mode === 'server' && (
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>{t('saveToServerHint')}</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
{auth.mode === 'server' && (
<button
className="btn btn-primary"
onClick={handleSaveTokenToServer}
disabled={recoveryLoading || !recoveryToken.trim()}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
{recoveryLoading ? <RefreshCw className="spin" size={14} /> : <Save size={14} />}
{t('saveToServer')}
</button>
)}
<button
className={auth.mode === 'server' ? 'btn btn-outline' : 'btn btn-primary'}
onClick={handleRecoveryLogin}
disabled={recoveryLoading || !recoveryToken.trim()}
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
>
<Key size={14} />
{t('useDirectly')}
</button>
</div>
{recoveryError && <p style={{ color: 'var(--error)', fontSize: '0.75rem', marginTop: '0.75rem' }}>{recoveryError}</p>}
</div>
</div>
) : activeTab === 'overview' ? (
/* === OVERVIEW / DASHBOARD === */
<div className="container page-enter" key="overview" style={{ paddingBottom: 0 }}>
<ErrorBoundary t={t}>
<Dashboard zones={zones} dnsRecordCounts={dnsRecordCounts} />
</ErrorBoundary>
</div>
) : selectedZone ? (
/* === ZONE DETAIL (DNS/SaaS/Cache/Speed/SSL) === */
<div className="page-enter" key={`${selectedZone.id}-${activeTab}`}>
<ErrorBoundary t={t}>
<ZoneDetail
ref={zoneDetailRef}
zone={selectedZone}
auth={auth}
authFetch={authFetch}
tab={activeTab}
onBack={() => { }}
t={t}
showToast={showToast}
onToggleZoneStorage={auth.mode === 'server' ? handleToggleZoneStorage : null}
zoneStorageLoading={zoneStorageLoading}
onUnbindZone={handleUnbindZone}
onRefreshZones={() => fetchZones(auth)}
onDnsCountUpdate={(zoneId, count) => setDnsRecordCounts(prev => ({ ...prev, [zoneId]: count }))}
/>
</ErrorBoundary>
</div>
) : (
/* === NO ZONE SELECTED but zones exist — prompt to select === */
<div className="container" style={{ textAlign: 'center', marginTop: '4rem' }}>
<div className="glass-card fade-in" style={{ maxWidth: '400px', margin: '0 auto', padding: '2rem' }}>
<Globe size={32} color="var(--primary)" style={{ marginBottom: '1rem' }} />
<h3 style={{ marginBottom: '0.5rem' }}>{t('selectZone')}</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>{t('domainSubtitle')}</p>
</div>
</div>
)}
</main>
{/* Footer Security Badges */}
<footer style={{
padding: '1.5rem 1rem', textAlign: 'center',
borderTop: '1px solid var(--border)', background: 'var(--card-bg)'
}}>
<SecurityBadges t={t} />
<p style={{ fontSize: '0.6rem', color: 'var(--text-muted)', marginTop: '0.5rem', letterSpacing: '0.02em' }}>
Cloudflare DNS Manager — {{ zh: '您的数据安全是我们的首要任务', en: 'Your data security is our top priority', ja: 'お客様のデータセキュリティは最優先事項です', ko: '여러분의 데이터 보안은 최우선 과제입니다' }[lang]}
</p>
<a
href="https://github.com/C-NF/cloudflare-dns-manager"
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '0.65rem', color: 'var(--text-muted)', textDecoration: 'none', opacity: 0.5, transition: 'opacity 0.2s', marginTop: '0.5rem' }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
GitHub
</a>
</footer>
</div>{/* end app-content */}
</div>{/* end app-layout */}
{/* Group Delete Confirmation Modal */}
{confirmGroupDelete && (
<div className="modal-overlay" style={{ zIndex: 10000 }}
onClick={(e) => { if (e.target === e.currentTarget) setConfirmGroupDelete(null); }}>
<div className="glass-card fade-in modal-content" role="dialog" aria-label={t('deleteGroup')} style={{ padding: '2rem', maxWidth: '440px', width: '90%', textAlign: 'center' }}>
<div style={{ marginBottom: '1rem' }}>
<Trash2 size={28} color="var(--danger)" />
</div>
<h2 style={{ marginBottom: '0.75rem', fontSize: '1.1rem' }}>{t('deleteGroup')}</h2>
<div style={{ marginBottom: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '6px' }}>
<span style={{
padding: '2px 6px', borderRadius: '4px', fontSize: '0.7rem', fontWeight: 700,
background: confirmGroupDelete.type === 'global_key' ? 'rgba(139, 92, 246, 0.12)' : 'rgba(59, 130, 246, 0.12)',
color: confirmGroupDelete.type === 'global_key' ? '#7c3aed' : '#2563eb'
}}>
{confirmGroupDelete.type === 'global_key' ? 'GK' : 'AT'}
</span>
<span style={{ fontWeight: 600 }}>{confirmGroupDelete.name}</span>
{confirmGroupDelete.tokenHint && (
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: 'var(--text-muted)' }}>{confirmGroupDelete.tokenHint}</span>
)}
</div>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{t('deleteGroupDesc').replace('{count}', confirmGroupDelete.zones.length)}
</p>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginBottom: '1rem', maxHeight: '100px', overflowY: 'auto', textAlign: 'left', padding: '0.5rem', background: 'var(--bg)', borderRadius: '6px' }}>
{confirmGroupDelete.zones.map(z => (
<div key={z.id} style={{ padding: '2px 0' }}>{z.name}</div>
))}
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button className="btn btn-outline" style={{ flex: 1 }} onClick={() => setConfirmGroupDelete(null)}>{t('cancel')}</button>
<button
className="btn"
style={{ flex: 1, background: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => handleDeleteGroup(confirmGroupDelete)}
>
{t('deleteGroup')}
</button>
</div>
</div>
</div>
)}
{/* Onboarding Tour */}
<OnboardingTour t={t} />
</div >
</Suspense>
</ThemeProvider>
</ToastProvider>
</AuthProvider>
);
};
export default App;