initial commit
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user