Files
sloth-manager/frontend/src/App.js
T
2026-06-02 01:00:27 +02:00

368 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { getProviders, getZones, getRecords, syncRecords, addRecord, updateRecord, deleteRecord, getMe, setToken } from './api/dns';
import RecordsTable from './components/RecordsTable';
import AddRecordForm from './components/AddRecordForm';
import Dashboard from './components/Dashboard';
import SettingsPage from './components/SettingsPage';
import ProviderOverview from './components/ProviderOverview';
import LoginPage from './components/LoginPage';
import ProfilePage from './components/ProfilePage';
import AuditPage from './components/AuditPage';
import SecretsPage from './components/SecretsPage';
import IpamPage from './components/IpamPage';
import DomainsPage from './components/DomainsPage';
import { useProviderColors, providerBadgeStyle } from './context/ProviderColors';
import { useTheme } from './context/Theme';
import ConfirmDialog from './components/ConfirmDialog';
import './App.css';
function formatSyncedAt(iso) {
if (!iso) return null;
const d = new Date(iso);
const date = d.toLocaleDateString('sv-SE'); // YYYY-MM-DD
const time = d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
return `${date} ${time}`;
}
// ─── Main app (shown when authenticated) ─────────────────────────────────────
function AppShell({ currentUser, onLogout }) {
const [providers, setProviders] = useState([]);
const [selectedProvider, setSelectedProvider] = useState(null);
const [zones, setZones] = useState([]);
const [selectedZone, setSelectedZone] = useState(null);
const [records, setRecords] = useState([]);
const [syncedAt, setSyncedAt] = useState(null);
const [loadingZones, setLoadingZones] = useState(false);
const [loadingRecords, setLoadingRecords] = useState(false);
const [syncing, setSyncing] = useState(false);
const [view, setView] = useState('dashboard');
const [showForm, setShowForm] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
const { colors: providerColors } = useProviderColors();
const { theme, toggleTheme } = useTheme();
const [deletingId, setDeletingId] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [error, setError] = useState('');
const [filter, setFilter] = useState('');
useEffect(() => {
getProviders()
.then(setProviders)
.catch(e => setError(e.message));
}, []);
useEffect(() => {
if (!selectedProvider) return;
setZones([]);
setSelectedZone(null);
setRecords([]);
setSyncedAt(null);
setError('');
setLoadingZones(true);
getZones(selectedProvider)
.then(setZones)
.catch(e => setError(e.message))
.finally(() => setLoadingZones(false));
}, [selectedProvider]);
const loadRecords = useCallback((provider, zone) => {
setLoadingRecords(true);
setError('');
getRecords(provider, zone.id)
.then(({ records, synced_at }) => {
setRecords(records);
setSyncedAt(synced_at);
})
.catch(e => setError(e.message))
.finally(() => setLoadingRecords(false));
}, []);
function selectZone(zone) {
setSelectedZone(zone);
setFilter('');
loadRecords(selectedProvider, zone);
}
async function handleSync() {
setSyncing(true);
setError('');
try {
const { records, synced_at } = await syncRecords(selectedProvider, selectedZone.id, selectedZone.name);
setRecords(records);
setSyncedAt(synced_at);
} catch (e) {
setError(e.message);
} finally {
setSyncing(false);
}
}
function openAddForm() { setEditingRecord(null); setShowForm(true); }
function openEditForm(record) { setEditingRecord(record); setShowForm(true); }
function closeForm() { setShowForm(false); setEditingRecord(null); }
async function handleFormSubmit(formData) {
if (editingRecord) {
const result = await updateRecord(selectedProvider, selectedZone.id, editingRecord.id, formData);
setRecords(rs => rs.map(r => r.id === editingRecord.id ? { ...formData, id: result.id ?? editingRecord.id } : r));
} else {
const result = await addRecord(selectedProvider, selectedZone.id, formData);
setRecords(rs => [...rs, { ...formData, id: result.id }]);
}
closeForm();
}
function handleDeleteRecord(record) {
setConfirmDelete(record);
}
async function confirmDeleteRecord() {
const record = confirmDelete;
setConfirmDelete(null);
setDeletingId(record.id);
try {
await deleteRecord(selectedProvider, selectedZone.id, record.id);
setRecords(rs => rs.filter(r => r.id !== record.id));
} catch (e) {
setError(e.message);
} finally {
setDeletingId(null);
}
}
const filteredRecords = records.filter(r => {
const q = filter.toLowerCase();
return !q || r.name.toLowerCase().includes(q) || r.content.toLowerCase().includes(q) || r.type.toLowerCase().includes(q);
});
return (
<div className="app">
<header className="app-header">
<h1>🦥 Sloth Manager</h1>
<div className="header-user">
<button className="btn-theme" onClick={toggleTheme} title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
<span className="header-username">{currentUser.username}</span>
<button className="btn-logout" onClick={onLogout}>Sign out</button>
</div>
</header>
<div className="layout">
<aside className="sidebar">
{/* Overview */}
<div className="sidebar-section">
<button
className={`sidebar-item ${view === 'dashboard' && !selectedProvider ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('dashboard'); }}
>
🏠 Overview
</button>
</div>
{/* DNS */}
<div className="sidebar-section">
<h2>DNS</h2>
<button
className={`sidebar-item ${view === 'domains' && !selectedProvider ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('domains'); }}
>
🌍 All Domains
</button>
{providers.length === 0 && <p className="hint">No providers configured.<br />Set up your .env file.</p>}
{providers.map(p => (
<button
key={p.id}
className={`sidebar-item ${selectedProvider === p.id ? 'active' : ''}`}
onClick={() => { setSelectedProvider(p.id); setView('dashboard'); }}
>
{p.name}
</button>
))}
</div>
{selectedProvider && (
<div className="sidebar-section">
<h2>Zones / Domains</h2>
{loadingZones && <p className="hint">Loading</p>}
{zones.map(z => (
<button
key={z.id}
className={`sidebar-item ${selectedZone?.id === z.id ? 'active' : ''}`}
onClick={() => selectZone(z)}
>
{z.name}
</button>
))}
</div>
)}
{/* Tools */}
<div className="sidebar-section">
<h2>Tools</h2>
<button
className={`sidebar-item ${view === 'secrets' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('secrets'); }}
>
🔑 Secrets
</button>
<button
className={`sidebar-item ${view === 'ipam' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('ipam'); }}
>
🖥 IP Addresses
</button>
</div>
{/* System */}
<div className="sidebar-section">
<h2>System</h2>
<button
className={`sidebar-item ${view === 'audit' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('audit'); }}
>
📋 Audit Log
</button>
<button
className={`sidebar-item ${view === 'settings' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('settings'); }}
>
Settings
</button>
<button
className={`sidebar-item ${view === 'profile' ? 'active' : ''}`}
onClick={() => { setSelectedProvider(null); setSelectedZone(null); setView('profile'); }}
>
👤 My Profile
</button>
</div>
<div className="sidebar-footer">
By <a href="https://bobbantech.com" target="_blank" rel="noreferrer">bobbantech</a>
</div>
</aside>
<main className="main">
{error && (
<div className="error-banner">
<strong>Error:</strong> {error}
<button className="dismiss" onClick={() => setError('')}></button>
</div>
)}
{!selectedProvider && view === 'settings' && <SettingsPage currentUser={currentUser} />}
{!selectedProvider && view === 'profile' && <ProfilePage currentUser={currentUser} />}
{!selectedProvider && view === 'domains' && (
<DomainsPage onNavigate={(provider, zoneId, zoneName) => {
setSelectedProvider(provider);
setView('dashboard');
// wait for zones to load then select
setSelectedZone({ id: zoneId, name: zoneName });
loadRecords(provider, { id: zoneId, name: zoneName });
}} />
)}
{!selectedProvider && view === 'secrets' && <SecretsPage />}
{!selectedProvider && view === 'ipam' && <IpamPage />}
{!selectedProvider && view === 'audit' && <AuditPage />}
{!selectedProvider && view === 'dashboard' && <Dashboard />}
{selectedProvider && !selectedZone && (
<ProviderOverview
provider={selectedProvider}
providerMeta={providers.find(p => p.id === selectedProvider)}
zones={zones}
onSelectZone={selectZone}
/>
)}
{selectedZone && (
<>
<div className="records-header">
<div>
<h2>{selectedZone.name}</h2>
<span className="badge" style={providerBadgeStyle(providerColors, selectedProvider)}>{selectedProvider}</span>
<span className="record-count">{records.length} records</span>
{syncedAt
? <span className="synced-at">Last synced: {formatSyncedAt(syncedAt)}</span>
: <span className="synced-at never">Never synced press Sync to load records</span>
}
</div>
<div className="records-actions">
<input
className="filter-input"
placeholder="Filter records…"
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<button className="btn-primary" onClick={openAddForm}>+ Add Record</button>
<button className="btn-sync" onClick={handleSync} disabled={syncing} title="Fetch latest records from provider">
{syncing ? 'Syncing…' : '⟳ Sync'}
</button>
</div>
</div>
{loadingRecords
? <p className="hint">Loading</p>
: !syncedAt
? <div className="empty-state"><p>Press <strong>Sync</strong> to fetch records from {selectedProvider}.</p></div>
: <RecordsTable records={filteredRecords} onDelete={handleDeleteRecord} onEdit={openEditForm} deleting={deletingId} zoneName={selectedZone?.name} />
}
</>
)}
</main>
</div>
{confirmDelete && (
<ConfirmDialog
title="Delete DNS Record"
message={`Delete ${confirmDelete.type} record "${confirmDelete.name}" → ${confirmDelete.content}?`}
confirmLabel="Delete"
danger
onConfirm={confirmDeleteRecord}
onCancel={() => setConfirmDelete(null)}
/>
)}
{showForm && (
<AddRecordForm
provider={selectedProvider}
zone={selectedZone?.name}
existing={editingRecord}
onSubmit={handleFormSubmit}
onCancel={closeForm}
/>
)}
</div>
);
}
// ─── Auth gate (always renders, no hook ordering issues) ──────────────────────
export default function App() {
const [currentUser, setCurrentUser] = useState(null);
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
getMe()
.then(user => { setCurrentUser(user); setAuthChecked(true); })
.catch(() => setAuthChecked(true));
}, []);
useEffect(() => {
const handler = () => setCurrentUser(null);
window.addEventListener('auth:logout', handler);
return () => window.removeEventListener('auth:logout', handler);
}, []);
function handleLogin(user) { setCurrentUser(user); }
function handleLogout() {
setToken(null);
setCurrentUser(null);
}
if (!authChecked) return null;
if (!currentUser) return <LoginPage onLogin={handleLogin} />;
return <AppShell currentUser={currentUser} onLogout={handleLogout} />;
}