initial commit

This commit is contained in:
2026-06-02 01:00:27 +02:00
commit d2a8072a47
64 changed files with 26467 additions and 0 deletions
+367
View File
@@ -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} />;
}