initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,3 @@
|
||||
HOST=127.0.0.1
|
||||
DANGEROUSLY_DISABLE_HOST_CHECK=true
|
||||
REACT_APP_API_URL=http://localhost:3001
|
||||
@@ -0,0 +1,22 @@
|
||||
# ── Stage 1: build the React app ─────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY public/ ./public/
|
||||
COPY src/ ./src/
|
||||
|
||||
# API calls are proxied by nginx so no REACT_APP_API_URL needed
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: serve with nginx ─────────────────────────────────────────────────
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API calls to the backend container
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# React app — all other routes serve index.html (client-side routing)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|svg|ico|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Generated
+17521
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "dns-manager-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Sloth Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
const BASE = (process.env.REACT_APP_API_URL || 'http://localhost:3001') + '/api';
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem('dns_token');
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
if (token) localStorage.setItem('dns_token', token);
|
||||
else localStorage.removeItem('dns_token');
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function handleResponse(res) {
|
||||
if (res.status === 401) {
|
||||
setToken(null);
|
||||
window.dispatchEvent(new Event('auth:logout'));
|
||||
throw new Error('Session expired — please log in again');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `HTTP ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function login(username, password) {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return handleResponse(res);
|
||||
}
|
||||
|
||||
export async function getMe() {
|
||||
return handleResponse(await fetch(`${BASE}/auth/me`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function changePassword(currentPassword, newPassword) {
|
||||
return handleResponse(await fetch(`${BASE}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getUsers() {
|
||||
return handleResponse(await fetch(`${BASE}/users`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function createUser(username, password) {
|
||||
return handleResponse(await fetch(`${BASE}/users`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteUser(id) {
|
||||
return handleResponse(await fetch(`${BASE}/users/${id}`, {
|
||||
method: 'DELETE', headers: authHeaders(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resetUserPassword(id, newPassword) {
|
||||
return handleResponse(await fetch(`${BASE}/users/${id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ newPassword }),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── IPAM ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getIpam() {
|
||||
return handleResponse(await fetch(`${BASE}/ipam`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function createIpEntry(data) {
|
||||
return handleResponse(await fetch(`${BASE}/ipam`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateIpEntry(id, data) {
|
||||
return handleResponse(await fetch(`${BASE}/ipam/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteIpEntry(id) {
|
||||
return handleResponse(await fetch(`${BASE}/ipam/${id}`, {
|
||||
method: 'DELETE', headers: authHeaders(),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Secrets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSecrets() {
|
||||
return handleResponse(await fetch(`${BASE}/secrets`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function createSecret(data) {
|
||||
return handleResponse(await fetch(`${BASE}/secrets`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateSecret(id, data) {
|
||||
return handleResponse(await fetch(`${BASE}/secrets/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(data),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteSecret(id) {
|
||||
return handleResponse(await fetch(`${BASE}/secrets/${id}`, {
|
||||
method: 'DELETE', headers: authHeaders(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAuditLog({ limit, offset, user, action, provider, category } = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (limit) qs.set('limit', limit);
|
||||
if (offset) qs.set('offset', offset);
|
||||
if (user) qs.set('user', user);
|
||||
if (action) qs.set('action', action);
|
||||
if (provider) qs.set('provider', provider);
|
||||
if (category) qs.set('category', category);
|
||||
return handleResponse(await fetch(`${BASE}/audit?${qs}`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getProviderHealth() {
|
||||
return handleResponse(await fetch(`${BASE}/health/providers`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getProviders() {
|
||||
return handleResponse(await fetch(`${BASE}/providers`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getStats() {
|
||||
return handleResponse(await fetch(`${BASE}/stats`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getAllDomains() {
|
||||
return handleResponse(await fetch(`${BASE}/domains`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getSettings() {
|
||||
return handleResponse(await fetch(`${BASE}/settings`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function saveSettings(settings) {
|
||||
return handleResponse(await fetch(`${BASE}/settings`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(settings),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
return handleResponse(await fetch(`${BASE}/settings/clear-cache`, { method: 'POST', headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function testNotification(gotify) {
|
||||
return handleResponse(await fetch(`${BASE}/settings/test-notification`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(gotify),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getSyncStatus(provider) {
|
||||
return handleResponse(await fetch(`${BASE}/sync-status/${provider}`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getZones(provider) {
|
||||
return handleResponse(await fetch(`${BASE}/zones/${provider}`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function getRecords(provider, zoneId) {
|
||||
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}`, { headers: authHeaders() }));
|
||||
}
|
||||
|
||||
export async function syncRecords(provider, zoneId, zoneName) {
|
||||
return handleResponse(await fetch(`${BASE}/records/sync/${provider}/${encodeURIComponent(zoneId)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ zoneName }),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addRecord(provider, zoneId, record) {
|
||||
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(record),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateRecord(provider, zoneId, recordId, record) {
|
||||
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}/${encodeURIComponent(recordId)}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(record),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteRecord(provider, zoneId, recordId) {
|
||||
return handleResponse(await fetch(`${BASE}/records/${provider}/${encodeURIComponent(zoneId)}/${encodeURIComponent(recordId)}`, {
|
||||
method: 'DELETE', headers: authHeaders(),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const ALL_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA', 'PTR'];
|
||||
const PIHOLE_RECORD_TYPES = ['A', 'AAAA', 'CNAME'];
|
||||
|
||||
// Pass `existing` prop to enter edit mode.
|
||||
export default function AddRecordForm({ provider, zone, onSubmit, onCancel, existing }) {
|
||||
const editMode = Boolean(existing);
|
||||
const RECORD_TYPES = provider === 'pihole' ? PIHOLE_RECORD_TYPES : ALL_RECORD_TYPES;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
type: existing?.type || 'A',
|
||||
name: existing?.name || '',
|
||||
content: existing?.content || '',
|
||||
ttl: existing?.ttl ?? 3600,
|
||||
priority: existing?.priority ?? '',
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const needsPriority = ['MX', 'SRV'].includes(form.type);
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
setForm(f => ({ ...f, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSubmit(form);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<h3>{editMode ? 'Edit DNS Record' : 'Add DNS Record'}</h3>
|
||||
<p className="modal-subtitle">
|
||||
<span className="badge">{provider}</span> {zone}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="record-form">
|
||||
<div className="form-row">
|
||||
<label>Type
|
||||
<select name="type" value={form.type} onChange={handleChange} disabled={editMode}>
|
||||
{RECORD_TYPES.map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>TTL (seconds)
|
||||
<input name="ttl" type="number" value={form.ttl} onChange={handleChange} min={1} required />
|
||||
</label>
|
||||
{needsPriority && (
|
||||
<label>Priority
|
||||
<input name="priority" type="number" value={form.priority} onChange={handleChange} min={0} required />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<label>Name / Host
|
||||
<input name="name" value={form.name} onChange={handleChange} placeholder="@ or subdomain" required />
|
||||
</label>
|
||||
<label>Content / Value
|
||||
<input name="content" value={form.content} onChange={handleChange} placeholder="e.g. 1.2.3.4" required />
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onCancel} disabled={loading}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? (editMode ? 'Saving…' : 'Adding…') : (editMode ? 'Save Changes' : 'Add Record')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { getAuditLog } from '../api/dns';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
|
||||
const ACTION_LABELS = {
|
||||
add: { label: 'Added', cls: 'audit-add' },
|
||||
update: { label: 'Updated', cls: 'audit-update' },
|
||||
delete: { label: 'Deleted', cls: 'audit-delete' },
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
dns: { label: 'DNS', cls: 'audit-cat-dns' },
|
||||
secret: { label: 'Secret', cls: 'audit-cat-secret' },
|
||||
ipam: { label: 'IP Address', cls: 'audit-cat-ipam' },
|
||||
user: { label: 'User', cls: 'audit-cat-user' },
|
||||
};
|
||||
|
||||
const SECRET_TYPE_LABELS = {
|
||||
api_token: 'API Token',
|
||||
ssl_certificate: 'SSL Certificate',
|
||||
password: 'Password',
|
||||
generic: 'Generic',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
function EntryDetail({ entry }) {
|
||||
const { category, action, record, prev, target } = entry;
|
||||
|
||||
if (category === 'dns') {
|
||||
if (!record?.name) return null;
|
||||
if (prev && action === 'update') {
|
||||
const contentChanged = prev.content !== record.content;
|
||||
return (
|
||||
<span className="audit-detail">
|
||||
{record.type} {record.name}
|
||||
{contentChanged && <> <s style={{ opacity: 0.5 }}>{prev.content}</s> → {record.content}</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="audit-detail">{record.type} {record.name} {record.content}</span>;
|
||||
}
|
||||
|
||||
if (category === 'secret') {
|
||||
if (!target) return null;
|
||||
const typeLabel = SECRET_TYPE_LABELS[target.type] ?? target.type;
|
||||
if (prev && action === 'update') {
|
||||
return <span className="audit-detail">{typeLabel} — {prev.name}{prev.name !== target.name ? ` → ${target.name}` : ''}</span>;
|
||||
}
|
||||
return <span className="audit-detail">{typeLabel} — {target.name}{target.expires ? ` (expires ${target.expires?.slice(0, 10)})` : ''}</span>;
|
||||
}
|
||||
|
||||
if (category === 'user') {
|
||||
if (!entry.target) return null;
|
||||
const isSelf = entry.user?.id === entry.target?.id;
|
||||
if (action === 'update') return <span className="audit-detail">{entry.target.username}{isSelf ? ' (own password)' : ' (password reset)'}</span>;
|
||||
return <span className="audit-detail">{entry.target.username}</span>;
|
||||
}
|
||||
|
||||
if (category === 'ipam') {
|
||||
if (!target) return null;
|
||||
if (prev && action === 'update') {
|
||||
const addrChanged = prev.address !== target.address;
|
||||
const labelChanged = prev.label !== target.label;
|
||||
return (
|
||||
<span className="audit-detail">
|
||||
{target.address}
|
||||
{labelChanged && <> ({prev.label} → {target.label})</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="audit-detail">{target.address}{target.label ? ` — ${target.label}` : ''}{target.vendor ? ` (${target.vendor})` : ''}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [filterUser, setFilterUser] = useState('');
|
||||
const [filterAction, setFilterAction] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [filterProvider, setFilterProvider] = useState('');
|
||||
|
||||
const load = useCallback((pg = 0) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
getAuditLog({
|
||||
limit: PAGE_SIZE,
|
||||
offset: pg * PAGE_SIZE,
|
||||
user: filterUser || undefined,
|
||||
action: filterAction || undefined,
|
||||
category: filterCategory || undefined,
|
||||
provider: filterProvider || undefined,
|
||||
})
|
||||
.then(({ entries, total }) => { setEntries(entries); setTotal(total); })
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [filterUser, filterAction, filterCategory, filterProvider]);
|
||||
|
||||
useEffect(() => { setPage(0); load(0); }, [load]);
|
||||
|
||||
function goToPage(pg) { setPage(pg); load(pg); }
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
function handleExport() {
|
||||
exportCsv(
|
||||
entries.map(e => ({
|
||||
timestamp: e.timestamp,
|
||||
user: e.user?.username ?? '',
|
||||
category: e.category,
|
||||
action: e.action,
|
||||
provider: e.provider ?? '',
|
||||
zone: e.zone ?? '',
|
||||
detail: e.category === 'dns'
|
||||
? `${e.record?.type ?? ''} ${e.record?.name ?? ''} ${e.record?.content ?? ''}`
|
||||
: e.category === 'secret'
|
||||
? `${e.target?.type ?? ''} — ${e.target?.name ?? ''}`
|
||||
: `${e.target?.address ?? ''} ${e.target?.label ?? ''}`,
|
||||
})),
|
||||
['timestamp','user','category','action','provider','zone','detail'],
|
||||
{ timestamp:'Time', user:'User', category:'Category', action:'Action', provider:'Provider', zone:'Zone', detail:'Detail' },
|
||||
'audit-log.csv'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<div>
|
||||
<h2>Audit Log</h2>
|
||||
<p className="dashboard-hint">All changes made through Sloth Manager. Newest first.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="audit-filters">
|
||||
<input className="filter-input" placeholder="Filter by user…" value={filterUser} onChange={e => setFilterUser(e.target.value)} />
|
||||
<select className="filter-input" value={filterCategory} onChange={e => setFilterCategory(e.target.value)}>
|
||||
<option value="">All categories</option>
|
||||
<option value="dns">DNS</option>
|
||||
<option value="secret">Secrets</option>
|
||||
<option value="ipam">IP Addresses</option>
|
||||
<option value="user">Users</option>
|
||||
</select>
|
||||
<select className="filter-input" value={filterAction} onChange={e => setFilterAction(e.target.value)}>
|
||||
<option value="">All actions</option>
|
||||
<option value="add">Added</option>
|
||||
<option value="update">Updated</option>
|
||||
<option value="delete">Deleted</option>
|
||||
</select>
|
||||
<input className="filter-input" placeholder="Filter by provider…" value={filterProvider} onChange={e => setFilterProvider(e.target.value)} style={{ display: filterCategory && filterCategory !== 'dns' ? 'none' : undefined }} />
|
||||
<span className="record-count">{total} entries</span>
|
||||
<button className="btn-export" onClick={handleExport}>⬇ Export CSV</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p className="hint">Loading…</p>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="empty-state"><p>No audit log entries yet.</p></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>User</th>
|
||||
<th>Category</th>
|
||||
<th>Action</th>
|
||||
<th>Detail</th>
|
||||
<th>Provider / Zone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(e => {
|
||||
const action = ACTION_LABELS[e.action] ?? { label: e.action, cls: '' };
|
||||
const category = CATEGORY_LABELS[e.category] ?? { label: e.category, cls: '' };
|
||||
return (
|
||||
<tr key={e.id}>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12, whiteSpace: 'nowrap' }}>{formatDate(e.timestamp)}</td>
|
||||
<td><span className="audit-user">{e.user?.username ?? '—'}</span></td>
|
||||
<td><span className={`audit-badge ${category.cls}`}>{category.label}</span></td>
|
||||
<td><span className={`audit-badge ${action.cls}`}>{action.label}</span></td>
|
||||
<td><EntryDetail entry={e} /></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{e.provider && <span style={{ textTransform: 'capitalize' }}>{e.provider}</span>}
|
||||
{e.zone && <span> / {e.zone}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="audit-pagination">
|
||||
<button className="btn-secondary" onClick={() => goToPage(page - 1)} disabled={page === 0}>← Prev</button>
|
||||
<span className="record-count">Page {page + 1} of {totalPages}</span>
|
||||
<button className="btn-secondary" onClick={() => goToPage(page + 1)} disabled={page >= totalPages - 1}>Next →</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Reusable confirmation dialog.
|
||||
*
|
||||
* Props:
|
||||
* title - dialog heading
|
||||
* message - body text
|
||||
* confirmLabel - text for the confirm button (default "Confirm")
|
||||
* danger - if true, confirm button uses the danger style
|
||||
* onConfirm - called when the user confirms
|
||||
* onCancel - called when the user cancels or clicks outside
|
||||
*/
|
||||
export default function ConfirmDialog({
|
||||
title = 'Are you sure?',
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal confirm-dialog" onClick={e => e.stopPropagation()}>
|
||||
<h3>{title}</h3>
|
||||
{message && <p className="confirm-message" style={{ whiteSpace: 'pre-line' }}>{message}</p>}
|
||||
<div className="form-actions" style={{ marginTop: 20 }}>
|
||||
<button className="btn-secondary" onClick={onCancel}>Cancel</button>
|
||||
<button
|
||||
className={danger ? 'btn-danger' : 'btn-primary'}
|
||||
onClick={onConfirm}
|
||||
autoFocus
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { getStats, getSecrets } from '../api/dns';
|
||||
|
||||
// ─── DNS chart colours ────────────────────────────────────────────────────────
|
||||
|
||||
const DNS_TYPE_COLORS = {
|
||||
A: '#60a5fa',
|
||||
AAAA: '#93c5fd',
|
||||
CNAME: '#c084fc',
|
||||
MX: '#4ade80',
|
||||
TXT: '#fb923c',
|
||||
NS: '#94a3b8',
|
||||
SRV: '#a3e635',
|
||||
CAA: '#f472b6',
|
||||
PTR: '#facc15',
|
||||
};
|
||||
|
||||
const SECRET_TYPE_COLORS = {
|
||||
api_token: '#6366f1',
|
||||
ssl_certificate: '#22d3ee',
|
||||
password: '#f472b6',
|
||||
generic: '#94a3b8',
|
||||
};
|
||||
|
||||
const SECRET_TYPE_LABELS = {
|
||||
api_token: 'API Token',
|
||||
ssl_certificate: 'SSL Certificate',
|
||||
password: 'Password',
|
||||
generic: 'Generic',
|
||||
};
|
||||
|
||||
function dnsTypeColor(type) { return DNS_TYPE_COLORS[type] ?? '#6366f1'; }
|
||||
function secretTypeColor(type) { return SECRET_TYPE_COLORS[type] ?? '#6366f1'; }
|
||||
|
||||
// ─── Shared components ────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, sub, accent }) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={accent ? { color: accent } : {}}>{value}</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
{sub && <div className="stat-sub">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title, hint }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 16, marginTop: 8 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{title}</h3>
|
||||
{hint && <p className="dashboard-hint" style={{ marginBottom: 0 }}>{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DnsTooltip = ({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const { type, count } = payload[0].payload;
|
||||
return (
|
||||
<div className="chart-tooltip">
|
||||
<span className="chart-tooltip-type">{type}</span>
|
||||
<span>{count} record{count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SecretTooltip = ({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const { type, count } = payload[0].payload;
|
||||
return (
|
||||
<div className="chart-tooltip">
|
||||
<span className="chart-tooltip-type">{SECRET_TYPE_LABELS[type] ?? type}</span>
|
||||
<span>{count} secret{count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [secrets, setSecrets] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getStats().then(setStats).catch(e => setError(e.message));
|
||||
getSecrets().then(setSecrets).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// DNS stats
|
||||
const totalRecords = stats?.recordTypes.reduce((s, r) => s + r.count, 0) ?? 0;
|
||||
|
||||
// Secrets stats
|
||||
const totalSecrets = secrets.length;
|
||||
const expiredCount = secrets.filter(s => s.status === 'expired').length;
|
||||
const warningCount = secrets.filter(s => s.status === 'warning').length;
|
||||
|
||||
const secretsByType = Object.entries(
|
||||
secrets.reduce((acc, s) => {
|
||||
acc[s.type] = (acc[s.type] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([type, count]) => ({ type, count }));
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<h2>Overview</h2>
|
||||
<p className="dashboard-hint">DNS statistics are based on locally cached records. Sync a zone to update them.</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="error" style={{ marginBottom: 16 }}>{error}</p>}
|
||||
|
||||
<div className="dashboard-grid">
|
||||
{/* ── DNS Column ── */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="DNS"
|
||||
hint={stats ? `${stats.totalZones} zone${stats.totalZones !== 1 ? 's' : ''} across ${Object.keys(stats.perProvider).length} provider${Object.keys(stats.perProvider).length !== 1 ? 's' : ''}` : ''}
|
||||
/>
|
||||
{stats && (
|
||||
<>
|
||||
<div className="stat-cards">
|
||||
<StatCard
|
||||
label="Domains"
|
||||
value={stats.totalZones}
|
||||
sub={Object.entries(stats.perProvider).map(([p, v]) => `${v.zones} on ${p}`).join(' · ')}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Records"
|
||||
value={totalRecords}
|
||||
sub={`${stats.recordTypes.length} type${stats.recordTypes.length !== 1 ? 's' : ''}`}
|
||||
/>
|
||||
<StatCard
|
||||
label="With MX"
|
||||
value={stats.zonesWithMx}
|
||||
sub={stats.totalZones > 0 ? `${Math.round((stats.zonesWithMx / stats.totalZones) * 100)}%` : null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stats.recordTypes.length > 0 ? (
|
||||
<div className="chart-section">
|
||||
<h3>Record Types</h3>
|
||||
<div className="chart-wrapper">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={stats.recordTypes}
|
||||
dataKey="count"
|
||||
nameKey="type"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={85}
|
||||
innerRadius={42}
|
||||
paddingAngle={2}
|
||||
label={({ type, percent }) => percent > 0.05 ? `${type} ${(percent * 100).toFixed(0)}%` : ''}
|
||||
labelLine={false}
|
||||
>
|
||||
{stats.recordTypes.map(entry => (
|
||||
<Cell key={entry.type} fill={dnsTypeColor(entry.type)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<DnsTooltip />} />
|
||||
<Legend formatter={v => <span style={{ color: 'var(--text)', fontSize: 12 }}>{v}</span>} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state" style={{ marginTop: 16 }}>
|
||||
<p>No records cached yet — sync a zone to populate statistics.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Secrets Column ── */}
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Secrets"
|
||||
hint="Expiry tracking for API tokens, certificates, and passwords."
|
||||
/>
|
||||
|
||||
<div className="stat-cards">
|
||||
<StatCard label="Monitored" value={totalSecrets} sub="total tracked" />
|
||||
<StatCard
|
||||
label="Expiring Soon"
|
||||
value={warningCount}
|
||||
sub="in warning window"
|
||||
accent={warningCount > 0 ? '#f59e0b' : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Expired"
|
||||
value={expiredCount}
|
||||
sub="need attention"
|
||||
accent={expiredCount > 0 ? '#ef4444' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{secretsByType.length > 0 ? (
|
||||
<div className="chart-section">
|
||||
<h3>Secrets by Type</h3>
|
||||
<div className="chart-wrapper">
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={secretsByType}
|
||||
dataKey="count"
|
||||
nameKey="type"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={85}
|
||||
innerRadius={42}
|
||||
paddingAngle={2}
|
||||
label={({ type, percent }) =>
|
||||
percent > 0.05 ? `${(percent * 100).toFixed(0)}%` : ''
|
||||
}
|
||||
labelLine={false}
|
||||
>
|
||||
{secretsByType.map(entry => (
|
||||
<Cell key={entry.type} fill={secretTypeColor(entry.type)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<SecretTooltip />} />
|
||||
<Legend formatter={v => <span style={{ color: 'var(--text)', fontSize: 12 }}>{SECRET_TYPE_LABELS[v] ?? v}</span>} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state" style={{ marginTop: 16 }}>
|
||||
<p>No secrets yet — go to <strong>🔑 Secrets</strong> to add one.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAllDomains } from '../api/dns';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return <span className="never-synced">Never synced</span>;
|
||||
const d = new Date(iso);
|
||||
return `${d.toLocaleDateString('sv-SE')} ${d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
export default function DomainsPage({ onNavigate }) {
|
||||
const { colors } = useProviderColors();
|
||||
const [domains, setDomains] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showDupes, setShowDupes] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getAllDomains()
|
||||
.then(setDomains)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const duplicateCount = domains.filter(d => d.duplicate).length;
|
||||
|
||||
const filtered = domains.filter(d => {
|
||||
if (showDupes && !d.duplicate) return false;
|
||||
if (!filter) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return d.name.toLowerCase().includes(q) ||
|
||||
d.entries.some(e => e.provider.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
function handleExport() {
|
||||
const rows = [];
|
||||
for (const d of filtered) {
|
||||
for (const e of d.entries) {
|
||||
rows.push({
|
||||
domain: d.name,
|
||||
provider: e.provider,
|
||||
record_count: e.record_count,
|
||||
synced_at: e.synced_at ?? '',
|
||||
duplicate: d.duplicate ? 'Yes' : 'No',
|
||||
});
|
||||
}
|
||||
}
|
||||
exportCsv(
|
||||
rows,
|
||||
['domain', 'provider', 'record_count', 'synced_at', 'duplicate'],
|
||||
{ domain: 'Domain', provider: 'Provider', record_count: 'Records', synced_at: 'Last Synced', duplicate: 'Duplicate' },
|
||||
'all-domains.csv'
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header">
|
||||
<div>
|
||||
<h2>All Domains</h2>
|
||||
<p className="dashboard-hint">
|
||||
All zones across every provider — based on locally cached data. Sync a zone to update it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
|
||||
|
||||
<div className="records-actions" style={{ marginBottom: 16 }}>
|
||||
<input
|
||||
className="filter-input"
|
||||
placeholder="Filter by domain or provider…"
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className={`btn-secondary ${showDupes ? 'active-filter' : ''}`}
|
||||
onClick={() => setShowDupes(v => !v)}
|
||||
title="Show only domains registered on multiple providers"
|
||||
>
|
||||
⚠ Duplicates {duplicateCount > 0 && <span className="dupe-badge">{duplicateCount}</span>}
|
||||
</button>
|
||||
<button className="btn-export" onClick={handleExport} title="Export to CSV">⬇ Export CSV</button>
|
||||
<span className="record-count">{filtered.length} domains</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="hint">Loading…</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>{domains.length === 0 ? 'No domains cached yet — sync a zone first.' : 'No domains match your filter.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Provider</th>
|
||||
<th>Records</th>
|
||||
<th>Last Synced</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(d =>
|
||||
d.entries.map((e, i) => (
|
||||
<tr
|
||||
key={`${d.name}-${e.provider}`}
|
||||
className={d.duplicate ? 'dupe-row' : ''}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onNavigate && onNavigate(e.provider, e.zone_id, d.name)}
|
||||
title="Click to open this zone"
|
||||
>
|
||||
<td className="name-cell">
|
||||
{i === 0 && (
|
||||
<>
|
||||
{d.name}
|
||||
{d.duplicate && <span className="dupe-tag" title="Same domain on multiple providers">⚠ duplicate</span>}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td><span className="badge" style={{ ...providerBadgeStyle(colors, e.provider), textTransform: 'capitalize' }}>{e.provider}</span></td>
|
||||
<td>{e.record_count}</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatDate(e.synced_at)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getIpam, createIpEntry, updateIpEntry, deleteIpEntry } from '../api/dns';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
||||
|
||||
function isIPv6(address) {
|
||||
return address.includes(':');
|
||||
}
|
||||
|
||||
function IpForm({ initial, onSave, onCancel }) {
|
||||
const [form, setForm] = useState({
|
||||
address: initial?.address ?? '',
|
||||
label: initial?.label ?? '',
|
||||
vendor: initial?.vendor ?? '',
|
||||
location: initial?.location ?? '',
|
||||
notes: initial?.notes ?? '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function handleChange(e) {
|
||||
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
try { await onSave(form); }
|
||||
catch (err) { setError(err.message); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>{initial ? 'Edit IP Address' : 'Add IP Address'}</h3>
|
||||
<form onSubmit={handleSubmit} className="record-form">
|
||||
<div className="form-row">
|
||||
<label>IP Address
|
||||
<input name="address" value={form.address} onChange={handleChange} placeholder="1.2.3.4 or 2001:db8::1" required autoFocus disabled={!!initial} />
|
||||
</label>
|
||||
<label>Label
|
||||
<input name="label" value={form.label} onChange={handleChange} placeholder="e.g. Web Server 1" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label>Vendor / Provider
|
||||
<input name="vendor" value={form.vendor} onChange={handleChange} placeholder="e.g. Hetzner, DigitalOcean" />
|
||||
</label>
|
||||
<label>Location / Region
|
||||
<input name="location" value={form.location} onChange={handleChange} placeholder="e.g. Frankfurt, US-East" />
|
||||
</label>
|
||||
</div>
|
||||
<label>Notes
|
||||
<input name="notes" value={form.notes} onChange={handleChange} placeholder="Optional notes" />
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : initial ? 'Save Changes' : 'Add IP'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsMatchBadges({ matches, colors }) {
|
||||
if (!matches || matches.length === 0) return <span style={{ color: 'var(--text-muted)', fontSize: 12 }}>—</span>;
|
||||
return (
|
||||
<div className="ipam-dns-matches">
|
||||
{matches.map((m, i) => (
|
||||
<span key={i} className="ipam-dns-match" title={`${m.type} ${m.name}`}>
|
||||
<span className="badge" style={{ ...providerBadgeStyle(colors, m.provider), fontSize: 10, padding: '1px 5px' }}>{m.provider}</span>
|
||||
<span className="ipam-dns-name">{m.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IpamPage() {
|
||||
const { colors } = useProviderColors();
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
getIpam()
|
||||
.then(setEntries)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleSave(data) {
|
||||
if (editing) {
|
||||
const updated = await updateIpEntry(editing.id, data);
|
||||
setEntries(e => e.map(x => x.id === editing.id ? updated : x));
|
||||
} else {
|
||||
const created = await createIpEntry(data);
|
||||
setEntries(e => [...e, created]);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteIpEntry(confirmDel.id);
|
||||
setEntries(e => e.filter(x => x.id !== confirmDel.id));
|
||||
setConfirmDel(null);
|
||||
}
|
||||
|
||||
const filtered = entries.filter(e => {
|
||||
if (!filter) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return (
|
||||
e.address.includes(q) ||
|
||||
(e.label || '').toLowerCase().includes(q) ||
|
||||
(e.vendor || '').toLowerCase().includes(q) ||
|
||||
(e.location || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const v4count = entries.filter(e => !isIPv6(e.address)).length;
|
||||
const v6count = entries.filter(e => isIPv6(e.address)).length;
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2>IP Addresses</h2>
|
||||
<p className="dashboard-hint">
|
||||
{entries.length} address{entries.length !== 1 ? 'es' : ''} stored
|
||||
{entries.length > 0 && ` · ${v4count} IPv4 · ${v6count} IPv6`}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>+ Add IP</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
|
||||
|
||||
<div className="records-actions" style={{ marginBottom: 16 }}>
|
||||
<input className="filter-input" placeholder="Filter by IP, label, vendor…" value={filter} onChange={e => setFilter(e.target.value)} />
|
||||
<button className="btn-export" onClick={() =>
|
||||
exportCsv(
|
||||
filtered.map(e => ({
|
||||
address: e.address, label: e.label, vendor: e.vendor,
|
||||
location: e.location, notes: e.notes,
|
||||
dns_matches: (e.dnsMatches || []).map(m => `${m.name} (${m.provider})`).join('; '),
|
||||
})),
|
||||
['address','label','vendor','location','notes','dns_matches'],
|
||||
{ address:'IP Address', label:'Label', vendor:'Vendor', location:'Location', notes:'Notes', dns_matches:'DNS Records' },
|
||||
'ip-addresses.csv'
|
||||
)
|
||||
}>⬇ Export CSV</button>
|
||||
<span className="record-count">{filtered.length} entries</span>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="hint">Loading…</p> : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>{entries.length === 0 ? 'No IP addresses stored yet — press + Add IP to get started.' : 'No entries match your filter.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th>Label</th>
|
||||
<th>Vendor</th>
|
||||
<th>Location</th>
|
||||
<th>DNS Records</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(e => (
|
||||
<tr key={e.id}>
|
||||
<td>
|
||||
<span className={`ipam-address ${isIPv6(e.address) ? 'ipam-v6' : 'ipam-v4'}`}>{e.address}</span>
|
||||
</td>
|
||||
<td>{e.label || <span style={{ color: 'var(--text-muted)' }}>—</span>}</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{e.vendor || '—'}</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{e.location || '—'}</td>
|
||||
<td><DnsMatchBadges matches={e.dnsMatches} colors={colors} /></td>
|
||||
<td className="actions-cell">
|
||||
{e.notes && <span className="secret-notes" title={e.notes}>ℹ</span>}
|
||||
<button className="btn-edit" onClick={() => { setEditing(e); setShowForm(true); }} title="Edit">✎</button>
|
||||
<button className="btn-delete" onClick={() => setConfirmDel(e)} title="Delete">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<IpForm
|
||||
initial={editing}
|
||||
onSave={handleSave}
|
||||
onCancel={() => { setShowForm(false); setEditing(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDel && (
|
||||
<ConfirmDialog
|
||||
title="Delete IP Address"
|
||||
message={`Remove ${confirmDel.address}${confirmDel.label ? ` (${confirmDel.label})` : ''}?`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDel(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { login, setToken } from '../api/dns';
|
||||
|
||||
export default function LoginPage({ onLogin }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await login(username, password);
|
||||
setToken(data.token);
|
||||
onLogin(data.user);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrapper">
|
||||
<div className="login-box">
|
||||
<div className="login-logo">🦥</div>
|
||||
<h1 className="login-title">Sloth Manager</h1>
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" className="btn-primary login-btn" disabled={loading}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="login-footer">By <a href="https://bobbantech.com" target="_blank" rel="noreferrer">bobbantech</a></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { changePassword } from '../api/dns';
|
||||
|
||||
export default function ProfilePage({ currentUser }) {
|
||||
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
setForm(f => ({ ...f, [name]: value }));
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (form.next !== form.confirm) { setError('New passwords do not match'); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await changePassword(form.current, form.next);
|
||||
setForm({ current: '', next: '', confirm: '' });
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 4000);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard" style={{ maxWidth: 480 }}>
|
||||
<div className="dashboard-header">
|
||||
<h2>My Profile</h2>
|
||||
</div>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>Account</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="profile-field">
|
||||
<span className="profile-label">Username</span>
|
||||
<span className="profile-value">{currentUser.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>Change Password</h3>
|
||||
<form onSubmit={handleSubmit} className="settings-form">
|
||||
<label>Current Password
|
||||
<input
|
||||
type="password"
|
||||
name="current"
|
||||
value={form.current}
|
||||
onChange={handleChange}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>New Password
|
||||
<input
|
||||
type="password"
|
||||
name="next"
|
||||
value={form.next}
|
||||
onChange={handleChange}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
<label>Confirm New Password
|
||||
<input
|
||||
type="password"
|
||||
name="confirm"
|
||||
value={form.confirm}
|
||||
onChange={handleChange}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{success && <p className="test-ok">✓ Password changed successfully.</p>}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getSyncStatus, syncRecords } from '../api/dns';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
||||
|
||||
const PROVIDER_LINKS = {
|
||||
cloudflare: { label: 'Open Cloudflare', url: 'https://dash.cloudflare.com/login' },
|
||||
loopia: { label: 'Open Loopia', url: 'https://customerzone.loopia.se' },
|
||||
pihole: { label: 'Open Pi-hole', url: null }, // dynamic — uses PIHOLE_URL
|
||||
azure: { label: 'Open Azure', url: 'https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.Network%2FdnsZones' },
|
||||
cpanel: { label: 'Open cPanel', url: null }, // dynamic — uses CPANEL_URL
|
||||
};
|
||||
|
||||
function formatSyncedAt(iso) {
|
||||
if (!iso) return <span className="never-synced">Never synced</span>;
|
||||
const d = new Date(iso);
|
||||
const date = d.toLocaleDateString('sv-SE');
|
||||
const time = d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
export default function ProviderOverview({ provider, providerMeta, zones, onSelectZone }) {
|
||||
const { colors } = useProviderColors();
|
||||
const link = PROVIDER_LINKS[provider];
|
||||
const externalUrl = (provider === 'pihole' || provider === 'cpanel') ? providerMeta?.url : link?.url;
|
||||
const [syncStatus, setSyncStatus] = useState({});
|
||||
const [syncingId, setSyncingId] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider) return;
|
||||
getSyncStatus(provider)
|
||||
.then(setSyncStatus)
|
||||
.catch(() => {});
|
||||
}, [provider]);
|
||||
|
||||
async function handleSync(e, zone) {
|
||||
e.stopPropagation(); // prevent row click opening the zone
|
||||
setSyncingId(zone.id);
|
||||
setError('');
|
||||
try {
|
||||
await syncRecords(provider, zone.id, zone.name);
|
||||
// Refresh sync status after sync completes
|
||||
const updated = await getSyncStatus(provider);
|
||||
setSyncStatus(updated);
|
||||
} catch (err) {
|
||||
setError(`Failed to sync ${zone.name}: ${err.message}`);
|
||||
} finally {
|
||||
setSyncingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="provider-overview">
|
||||
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2 style={{ textTransform: 'capitalize' }}>{provider}</h2>
|
||||
<p className="dashboard-hint">{zones.length} zone{zones.length !== 1 ? 's' : ''} — click a row to manage its records, or sync directly from here.</p>
|
||||
</div>
|
||||
<button className="btn-export" onClick={() =>
|
||||
exportCsv(
|
||||
zones.map(z => ({ domain: z.name, synced_at: syncStatus[z.id]?.synced_at ?? '', records: syncStatus[z.id]?.record_count ?? '' })),
|
||||
['domain', 'records', 'synced_at'],
|
||||
{ domain: 'Domain', records: 'Records', synced_at: 'Last Synced' },
|
||||
`${provider}-domains.csv`
|
||||
)
|
||||
} title="Export to CSV">⬇ Export CSV</button>
|
||||
{externalUrl && (
|
||||
<a href={externalUrl} target="_blank" rel="noreferrer" className="btn-secondary" style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}>
|
||||
{link?.label ?? `Open ${provider}`} ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner" style={{ marginBottom: 16 }}>
|
||||
<strong>Error:</strong> {error}
|
||||
<button className="dismiss" onClick={() => setError('')}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone / Domain</th>
|
||||
<th>Records</th>
|
||||
<th>Last Synced</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zones.map(z => {
|
||||
const status = syncStatus[z.id];
|
||||
const isSyncing = syncingId === z.id;
|
||||
return (
|
||||
<tr key={z.id} className="zone-row" onClick={() => onSelectZone(z)}>
|
||||
<td className="name-cell">{z.name}</td>
|
||||
<td>{status?.record_count ?? '—'}</td>
|
||||
<td>{formatSyncedAt(status?.synced_at)}</td>
|
||||
<td className="actions-cell">
|
||||
<button
|
||||
className="btn-sync"
|
||||
onClick={(e) => handleSync(e, z)}
|
||||
disabled={isSyncing}
|
||||
title="Sync records from provider"
|
||||
>
|
||||
{isSyncing ? '…' : '⟳'}
|
||||
</button>
|
||||
<span className="zone-open" onClick={() => onSelectZone(z)}>Open →</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'content', label: 'Content' },
|
||||
{ key: 'ttl', label: 'TTL' },
|
||||
{ key: 'priority', label: 'Priority' },
|
||||
];
|
||||
|
||||
function compareValues(a, b) {
|
||||
// nulls always last
|
||||
if (a === null || a === undefined) return 1;
|
||||
if (b === null || b === undefined) return -1;
|
||||
// numeric comparison when both are numbers
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
// natural sort for strings (handles "sub10" > "sub9" correctly)
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export default function RecordsTable({ records, onDelete, onEdit, deleting, zoneName = 'records' }) {
|
||||
function handleExport() {
|
||||
exportCsv(
|
||||
records,
|
||||
['type', 'name', 'content', 'ttl', 'priority'],
|
||||
{ type: 'Type', name: 'Name', content: 'Content', ttl: 'TTL', priority: 'Priority' },
|
||||
`${zoneName}-dns-records.csv`
|
||||
);
|
||||
}
|
||||
const [sortKey, setSortKey] = useState('name');
|
||||
const [sortDir, setSortDir] = useState('asc');
|
||||
|
||||
function handleSort(key) {
|
||||
if (key === sortKey) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...records];
|
||||
copy.sort((a, b) => {
|
||||
const cmp = compareValues(a[sortKey], b[sortKey]);
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return copy;
|
||||
}, [records, sortKey, sortDir]);
|
||||
|
||||
function indicator(key) {
|
||||
if (key !== sortKey) return <span className="sort-icon inactive">↕</span>;
|
||||
return <span className="sort-icon active">{sortDir === 'asc' ? '↑' : '↓'}</span>;
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return <p className="empty">No DNS records found for this zone.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-actions">
|
||||
<button className="btn-export" onClick={handleExport} title="Export to CSV">⬇ Export CSV</button>
|
||||
</div>
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{COLUMNS.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="sortable"
|
||||
onClick={() => handleSort(col.key)}
|
||||
title={`Sort by ${col.label}`}
|
||||
>
|
||||
{col.label} {indicator(col.key)}
|
||||
</th>
|
||||
))}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td><span className={`type-badge type-${r.type?.toLowerCase() ?? 'unknown'}`}>{r.type ?? '?'}</span></td>
|
||||
<td className="name-cell">{r.name}</td>
|
||||
<td className="content-cell">{r.content}</td>
|
||||
<td>{r.ttl === 1 || r.ttl === null ? 'Auto' : r.ttl}</td>
|
||||
<td>{r.priority ?? '—'}</td>
|
||||
<td className="actions-cell">
|
||||
<button className="btn-edit" onClick={() => onEdit(r)} title="Edit record">✎</button>
|
||||
<button
|
||||
className="btn-delete"
|
||||
onClick={() => onDelete(r)}
|
||||
disabled={deleting === r.id}
|
||||
title="Delete record"
|
||||
>
|
||||
{deleting === r.id ? '…' : '✕'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSecrets, createSecret, updateSecret, deleteSecret } from '../api/dns';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
api_token: 'API Token',
|
||||
ssl_certificate: 'SSL Certificate',
|
||||
password: 'Password',
|
||||
generic: 'Generic',
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
expired: { label: 'Expired', cls: 'secret-expired' },
|
||||
warning: { label: 'Expiring', cls: 'secret-warning' },
|
||||
ok: { label: 'OK', cls: 'secret-ok' },
|
||||
};
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleDateString('sv-SE');
|
||||
}
|
||||
|
||||
function SecretForm({ initial, onSave, onCancel }) {
|
||||
const [form, setForm] = useState({
|
||||
name: initial?.name ?? '',
|
||||
type: initial?.type ?? 'api_token',
|
||||
description: initial?.description ?? '',
|
||||
expires_at: initial?.expires_at ? initial.expires_at.slice(0, 10) : '',
|
||||
warning_days: initial?.warning_days ?? 30,
|
||||
notes: initial?.notes ?? '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
setForm(f => ({ ...f, [name]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await onSave({ ...form, warning_days: Number(form.warning_days) });
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>{initial ? 'Edit Secret' : 'Add Secret'}</h3>
|
||||
<form onSubmit={handleSubmit} className="record-form">
|
||||
<div className="form-row">
|
||||
<label>Name
|
||||
<input name="name" value={form.name} onChange={handleChange} required autoFocus />
|
||||
</label>
|
||||
<label>Type
|
||||
<select name="type" value={form.type} onChange={handleChange}>
|
||||
{Object.entries(TYPE_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>Description
|
||||
<input name="description" value={form.description} onChange={handleChange} placeholder="What is this secret used for?" />
|
||||
</label>
|
||||
<div className="form-row">
|
||||
<label>Expiry Date
|
||||
<input name="expires_at" type="date" value={form.expires_at} onChange={handleChange} required />
|
||||
</label>
|
||||
<label>Warn (days before)
|
||||
<input name="warning_days" type="number" min={1} value={form.warning_days} onChange={handleChange} required />
|
||||
</label>
|
||||
</div>
|
||||
<label>Notes
|
||||
<input name="notes" value={form.notes} onChange={handleChange} placeholder="Optional notes" />
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onCancel} disabled={saving}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : initial ? 'Save Changes' : 'Add Secret'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SecretsPage() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [confirmDel, setConfirmDel] = useState(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
getSecrets()
|
||||
.then(setItems)
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleSave(data) {
|
||||
if (editing) {
|
||||
const updated = await updateSecret(editing.id, data);
|
||||
setItems(i => i.map(x => x.id === editing.id ? { ...updated, daysLeft: x.daysLeft, status: x.status } : x));
|
||||
} else {
|
||||
await createSecret(data);
|
||||
load(); // reload to get fresh status
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
await deleteSecret(confirmDel.id);
|
||||
setItems(i => i.filter(x => x.id !== confirmDel.id));
|
||||
setConfirmDel(null);
|
||||
}
|
||||
|
||||
const filtered = items.filter(s => {
|
||||
if (filterStatus && s.status !== filterStatus) return false;
|
||||
if (!filter) return true;
|
||||
const q = filter.toLowerCase();
|
||||
return s.name.toLowerCase().includes(q) || (s.description || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const expiredCount = items.filter(s => s.status === 'expired').length;
|
||||
const warningCount = items.filter(s => s.status === 'warning').length;
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="dashboard-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h2>Secrets</h2>
|
||||
<p className="dashboard-hint">Track expiry dates for API tokens, certificates, passwords, and other secrets.</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={() => { setEditing(null); setShowForm(true); }}>+ Add Secret</button>
|
||||
</div>
|
||||
|
||||
{(expiredCount > 0 || warningCount > 0) && (
|
||||
<div className="secrets-alert-banner">
|
||||
{expiredCount > 0 && <span className="secret-expired-text">✕ {expiredCount} expired</span>}
|
||||
{warningCount > 0 && <span className="secret-warning-text">⚠ {warningCount} expiring soon</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-banner" style={{ marginBottom: 16 }}><strong>Error:</strong> {error}</div>}
|
||||
|
||||
<div className="records-actions" style={{ marginBottom: 16 }}>
|
||||
<input className="filter-input" placeholder="Filter secrets…" value={filter} onChange={e => setFilter(e.target.value)} />
|
||||
<select className="filter-input" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="warning">Expiring soon</option>
|
||||
<option value="ok">OK</option>
|
||||
</select>
|
||||
<button className="btn-export" onClick={() =>
|
||||
exportCsv(
|
||||
filtered.map(s => ({ name: s.name, type: TYPE_LABELS[s.type] ?? s.type, description: s.description, expires_at: s.expires_at?.slice(0,10), warning_days: s.warning_days, status: s.status, days_left: s.daysLeft, notes: s.notes })),
|
||||
['name','type','description','expires_at','warning_days','status','days_left','notes'],
|
||||
{ name:'Name', type:'Type', description:'Description', expires_at:'Expires', warning_days:'Warn Days', status:'Status', days_left:'Days Left', notes:'Notes' },
|
||||
'secrets.csv'
|
||||
)
|
||||
}>⬇ Export CSV</button>
|
||||
<span className="record-count">{filtered.length} secrets</span>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="hint">Loading…</p> : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>{items.length === 0 ? 'No secrets tracked yet — press + Add Secret to get started.' : 'No secrets match your filter.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
<th>Expires</th>
|
||||
<th>Days Left</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(s => {
|
||||
const sc = STATUS_CONFIG[s.status] ?? STATUS_CONFIG.ok;
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td className="name-cell">
|
||||
{s.name}
|
||||
{s.notes && <span className="secret-notes" title={s.notes}> ℹ</span>}
|
||||
</td>
|
||||
<td><span className="type-badge">{TYPE_LABELS[s.type] ?? s.type}</span></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{s.description || '—'}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>{formatDate(s.expires_at)}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||
{s.daysLeft < 0 ? `${Math.abs(s.daysLeft)}d ago` : `${s.daysLeft}d`}
|
||||
</td>
|
||||
<td><span className={`secret-status-badge ${sc.cls}`}>{sc.label}</span></td>
|
||||
<td className="actions-cell">
|
||||
<button className="btn-edit" onClick={() => { setEditing(s); setShowForm(true); }} title="Edit">✎</button>
|
||||
<button className="btn-delete" onClick={() => setConfirmDel(s)} title="Delete">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<SecretForm
|
||||
initial={editing}
|
||||
onSave={handleSave}
|
||||
onCancel={() => { setShowForm(false); setEditing(null); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDel && (
|
||||
<ConfirmDialog
|
||||
title="Delete Secret"
|
||||
message={`Delete "${confirmDel.name}"? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDel(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getSettings, saveSettings, testNotification, clearCache, getUsers, createUser, deleteUser, getProviderHealth } from '../api/dns';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import { exportCsv } from '../utils/exportCsv';
|
||||
import { useProviderColors, providerBadgeStyle } from '../context/ProviderColors';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'notifications', label: 'Notifications' },
|
||||
{ id: 'providers', label: 'Providers' },
|
||||
{ id: 'status', label: 'Provider Status' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'cache', label: 'Cache' },
|
||||
];
|
||||
|
||||
// ─── Notifications tab ────────────────────────────────────────────────────────
|
||||
|
||||
function NotificationsTab() {
|
||||
const [form, setForm] = useState({ enabled: false, url: '', token: '', priority: 5 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then(s => setForm({ ...s.gotify }))
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function handleChange(e) {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setForm(f => ({ ...f, [name]: type === 'checkbox' ? checked : value }));
|
||||
setSaved(false);
|
||||
setTestResult(null);
|
||||
}
|
||||
|
||||
async function handleSave(e) {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError(''); setSaved(false);
|
||||
try {
|
||||
await saveSettings({ gotify: { ...form, priority: Number(form.priority) } });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true); setTestResult(null); setError('');
|
||||
try {
|
||||
await testNotification({ url: form.url, token: form.token, priority: Number(form.priority) });
|
||||
setTestResult({ ok: true, message: 'Notification sent successfully.' });
|
||||
} catch (err) {
|
||||
setTestResult({ ok: false, message: err.message });
|
||||
} finally { setTesting(false); }
|
||||
}
|
||||
|
||||
if (loading) return <p className="hint">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div className="settings-grid">
|
||||
<section className="settings-section">
|
||||
<h3>Gotify Notifications</h3>
|
||||
<p className="settings-desc">
|
||||
Send a notification to a Gotify instance whenever a DNS record is added, updated, or deleted.
|
||||
</p>
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
<form onSubmit={handleSave} className="settings-form">
|
||||
<label className="toggle-row">
|
||||
<span>Enable notifications</span>
|
||||
<input type="checkbox" name="enabled" checked={form.enabled} onChange={handleChange} className="toggle" />
|
||||
</label>
|
||||
<label>Gotify URL
|
||||
<input name="url" type="url" value={form.url} onChange={handleChange} placeholder="http://gotify.example.com" disabled={!form.enabled} />
|
||||
</label>
|
||||
<label>App Token
|
||||
<input name="token" type="password" value={form.token} onChange={handleChange} placeholder="Your Gotify app token" disabled={!form.enabled} />
|
||||
</label>
|
||||
<label>Priority <span className="settings-hint">(1 = low, 10 = high)</span>
|
||||
<input name="priority" type="number" min={1} max={10} value={form.priority} onChange={handleChange} disabled={!form.enabled} />
|
||||
</label>
|
||||
{testResult && (
|
||||
<p className={testResult.ok ? 'test-ok' : 'test-fail'}>
|
||||
{testResult.ok ? '✓' : '✕'} {testResult.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={handleTest} disabled={testing || !form.url || !form.token}>
|
||||
{testing ? 'Sending…' : 'Send Test'}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving…' : saved ? '✓ Saved' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>About Gotify</h3>
|
||||
<p className="settings-desc" style={{ lineHeight: 1.8 }}>
|
||||
<a href="https://gotify.net" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)' }}>Gotify</a> is a self-hosted push notification server.<br /><br />
|
||||
<strong>To get started:</strong><br />
|
||||
1. Log in to your Gotify instance<br />
|
||||
2. Go to <strong>Apps → Create application</strong><br />
|
||||
3. Copy the generated token and paste it into App Token<br /><br />
|
||||
Notifications fire on every record add, update, or delete — not on sync.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Users tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function UsersTab({ currentUser }) {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ username: '', password: '' });
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [confirmUser, setConfirmUser] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
useEffect(() => { getUsers().then(setUsers).catch(() => {}); }, []);
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault();
|
||||
setError(''); setSuccess('');
|
||||
setCreating(true);
|
||||
try {
|
||||
const user = await createUser(newUser.username, newUser.password);
|
||||
setUsers(u => [...u, user]);
|
||||
setNewUser({ username: '', password: '' });
|
||||
setShowForm(false);
|
||||
setSuccess(`User "${user.username}" created.`);
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setCreating(false); }
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setShowForm(false);
|
||||
setNewUser({ username: '', password: '' });
|
||||
setError('');
|
||||
}
|
||||
|
||||
async function confirmDeleteUser() {
|
||||
const user = confirmUser;
|
||||
setConfirmUser(null);
|
||||
try {
|
||||
await deleteUser(user.id);
|
||||
setUsers(u => u.filter(x => x.id !== user.id));
|
||||
} catch (err) { setError(err.message); }
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const date = d.toLocaleDateString('sv-SE');
|
||||
const time = d.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit' });
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmUser && (
|
||||
<ConfirmDialog
|
||||
title="Delete User"
|
||||
message={`Remove "${confirmUser.username}" from Sloth Manager? This cannot be undone.`}
|
||||
confirmLabel="Delete User"
|
||||
danger
|
||||
onConfirm={confirmDeleteUser}
|
||||
onCancel={() => setConfirmUser(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="records-header" style={{ marginBottom: 16 }}>
|
||||
<div>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600 }}>Users</h3>
|
||||
<p className="settings-desc" style={{ marginBottom: 0 }}>Manage who can access Sloth Manager.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn-export" onClick={() =>
|
||||
exportCsv(users, ['username', 'createdAt'], { username: 'Username', createdAt: 'Created' }, 'users.csv')
|
||||
} title="Export to CSV">⬇ Export CSV</button>
|
||||
<button className="btn-primary" onClick={() => setShowForm(true)} disabled={showForm}>+ Add User</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{success && <p className="test-ok" style={{ marginBottom: 12 }}>✓ {success}</p>}
|
||||
|
||||
<div className="table-wrapper" style={{ marginBottom: showForm ? 24 : 0 }}>
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<span className="user-name">{u.username}</span>
|
||||
{u.id === currentUser?.id && (
|
||||
<span className="badge" style={{ fontSize: 10, marginLeft: 8 }}>you</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 13 }}>{formatDate(u.createdAt)}</td>
|
||||
<td className="actions-cell">
|
||||
{u.id !== currentUser?.id && (
|
||||
<button className="btn-delete" onClick={() => setConfirmUser(u)} title="Delete user">✕</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<section className="settings-section" style={{ marginTop: 0 }}>
|
||||
<h4 style={{ fontSize: 14, fontWeight: 600, marginBottom: 16 }}>New User</h4>
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
<form onSubmit={handleCreate} className="settings-form">
|
||||
<label>Username
|
||||
<input value={newUser.username} onChange={e => setNewUser(f => ({ ...f, username: e.target.value }))} required autoFocus />
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" value={newUser.password} onChange={e => setNewUser(f => ({ ...f, password: e.target.value }))} required minLength={6} />
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" onClick={handleCancel} disabled={creating}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={creating}>{creating ? 'Creating…' : 'Create User'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cache tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function CacheTab() {
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [cleared, setCleared] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function doClearCache() {
|
||||
setConfirmClear(false);
|
||||
setClearing(true); setError('');
|
||||
try {
|
||||
await clearCache();
|
||||
setCleared(true);
|
||||
setTimeout(() => setCleared(false), 3000);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setClearing(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmClear && (
|
||||
<ConfirmDialog
|
||||
title="Clear Record Cache"
|
||||
message="This will delete all locally cached DNS records. All zones will need to be re-synced afterwards."
|
||||
confirmLabel="Clear Cache"
|
||||
danger
|
||||
onConfirm={doClearCache}
|
||||
onCancel={() => setConfirmClear(false)}
|
||||
/>
|
||||
)}
|
||||
<section className="settings-section">
|
||||
<h3>Record Cache</h3>
|
||||
<p className="settings-desc">
|
||||
The record cache stores a local copy of all synced DNS records in <code>dns-cache.json</code>.
|
||||
Clear it if you experience stale or corrupt data — all zones will need to be re-synced afterwards.
|
||||
</p>
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
|
||||
<button type="button" className="btn-danger" onClick={() => setConfirmClear(true)} disabled={clearing}>
|
||||
{clearing ? 'Clearing…' : cleared ? '✓ Cache cleared' : 'Clear Cache'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Providers tab ───────────────────────────────────────────────────────────
|
||||
|
||||
const PROVIDER_NAMES = {
|
||||
cloudflare: 'Cloudflare',
|
||||
loopia: 'Loopia',
|
||||
pihole: 'Pi-hole',
|
||||
azure: 'Azure DNS',
|
||||
cpanel: 'cPanel',
|
||||
};
|
||||
|
||||
function ProvidersTab() {
|
||||
const { colors, setColors } = useProviderColors();
|
||||
const [local, setLocal] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => { setLocal({ ...colors }); }, [colors]);
|
||||
|
||||
function handleChange(provider, value) {
|
||||
setLocal(c => ({ ...c, [provider]: value }));
|
||||
setSaved(false);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true); setError(''); setSaved(false);
|
||||
try {
|
||||
await saveSettings({ providerColors: local });
|
||||
setColors(local);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<h3>Provider Tag Colors</h3>
|
||||
<p className="settings-desc">Customize the color of each provider's badge throughout the app.</p>
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
<div className="provider-colors-list">
|
||||
{Object.entries(PROVIDER_NAMES).map(([id, name]) => (
|
||||
<div key={id} className="provider-color-row">
|
||||
<span className="badge" style={providerBadgeStyle(local, id)}>{name}</span>
|
||||
<label className="color-label">
|
||||
<input
|
||||
type="color"
|
||||
value={local[id] ?? '#6366f1'}
|
||||
onChange={e => handleChange(id, e.target.value)}
|
||||
className="color-input"
|
||||
/>
|
||||
<span className="color-hex">{local[id] ?? '#6366f1'}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-actions" style={{ marginTop: 20 }}>
|
||||
<button className="btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : saved ? '✓ Saved' : 'Save Colors'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Status tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusTab() {
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function runCheck() {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await getProviderHealth();
|
||||
setResults(data);
|
||||
setLastChecked(new Date());
|
||||
} catch (err) { setError(err.message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
ok: { icon: '✓', label: 'Connected', cls: 'health-ok' },
|
||||
error: { icon: '✕', label: 'Error', cls: 'health-error' },
|
||||
unconfigured: { icon: '—', label: 'Not configured', cls: 'health-unconfigured' },
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="settings-section" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div>
|
||||
<h3>Provider Status</h3>
|
||||
<p className="settings-desc" style={{ marginBottom: 0 }}>
|
||||
Test connectivity to each configured provider.
|
||||
{lastChecked && <span> Last checked: {lastChecked.toLocaleTimeString('sv-SE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={runCheck} disabled={loading}>
|
||||
{loading ? 'Checking…' : '⟳ Run Check'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="error" style={{ marginBottom: 12 }}>{error}</p>}
|
||||
|
||||
{results.length === 0 && !loading && (
|
||||
<p className="hint" style={{ marginTop: 16 }}>Press <strong>Run Check</strong> to test all provider connections.</p>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="table-wrapper" style={{ marginTop: 16 }}>
|
||||
<table className="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Provider</th>
|
||||
<th>Status</th>
|
||||
<th>Latency</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map(r => {
|
||||
const cfg = STATUS_CONFIG[r.status] ?? STATUS_CONFIG.unconfigured;
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td style={{ fontWeight: 500 }}>{r.name}</td>
|
||||
<td>
|
||||
<span className={`health-badge ${cfg.cls}`}>
|
||||
{cfg.icon} {cfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{r.latency !== null ? `${r.latency} ms` : '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: 13, color: r.error ? '#fca5a5' : 'var(--text-muted)' }}>
|
||||
{r.error ?? (r.status === 'unconfigured' ? 'No credentials in .env' : 'OK')}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Settings page ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage({ currentUser }) {
|
||||
const [activeTab, setActiveTab] = useState('notifications');
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<div className="dashboard-header">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
|
||||
<div className="settings-tabs">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`settings-tab ${activeTab === t.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="settings-tab-content">
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'providers' && <ProvidersTab />}
|
||||
{activeTab === 'status' && <StatusTab />}
|
||||
{activeTab === 'users' && <UsersTab currentUser={currentUser} />}
|
||||
{activeTab === 'cache' && <CacheTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { getSettings } from '../api/dns';
|
||||
|
||||
const DEFAULTS = {
|
||||
cloudflare: '#f6821f',
|
||||
loopia: '#2ecc71',
|
||||
pihole: '#96060c',
|
||||
azure: '#0078d4',
|
||||
cpanel: '#ff6c2c',
|
||||
};
|
||||
|
||||
const ProviderColorsContext = createContext(DEFAULTS);
|
||||
|
||||
export function ProviderColorsProvider({ children }) {
|
||||
const [colors, setColors] = useState(DEFAULTS);
|
||||
|
||||
useEffect(() => {
|
||||
getSettings()
|
||||
.then(s => setColors({ ...DEFAULTS, ...(s.providerColors ?? {}) }))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ProviderColorsContext.Provider value={{ colors, setColors }}>
|
||||
{children}
|
||||
</ProviderColorsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProviderColors() {
|
||||
return useContext(ProviderColorsContext);
|
||||
}
|
||||
|
||||
export function providerBadgeStyle(colors, provider) {
|
||||
const color = colors[provider?.toLowerCase()] ?? '#6366f1';
|
||||
return {
|
||||
background: `${color}22`,
|
||||
color,
|
||||
border: `1px solid ${color}55`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const ThemeContext = createContext({ theme: 'dark', toggleTheme: () => {} });
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setTheme] = useState(() => localStorage.getItem('dns_theme') || 'dark');
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('dns_theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme(t => t === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { ProviderColorsProvider } from './context/ProviderColors';
|
||||
import { ThemeProvider } from './context/Theme';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<ProviderColorsProvider>
|
||||
<App />
|
||||
</ProviderColorsProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Download an array of objects as a CSV file.
|
||||
* @param {object[]} rows - data rows
|
||||
* @param {string[]} columns - keys to include (in order)
|
||||
* @param {object} headers - optional { key: 'Label' } overrides
|
||||
* @param {string} filename
|
||||
*/
|
||||
export function exportCsv(rows, columns, headers = {}, filename = 'export.csv') {
|
||||
const header = columns.map(c => headers[c] ?? c).join(',');
|
||||
|
||||
const body = rows.map(row =>
|
||||
columns.map(col => {
|
||||
const val = row[col] ?? '';
|
||||
const str = String(val);
|
||||
// Wrap in quotes if the value contains a comma, quote, or newline
|
||||
return str.includes(',') || str.includes('"') || str.includes('\n')
|
||||
? `"${str.replace(/"/g, '""')}"`
|
||||
: str;
|
||||
}).join(',')
|
||||
);
|
||||
|
||||
const csv = [header, ...body].join('\r\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Reference in New Issue
Block a user