New Domains page listing when each domain registration expires, read from the registry. Domains behind the DNS zones already synced are picked up automatically; others can be added by hand. You're reminded daily from N days before expiry (Settings > Notifications, default 30) until it's renewed, and told when an expiry date hasn't been refreshable for several days so a stale date isn't trusted silently. RDAP alone would not have covered this homelab: .se, .nu, .io, .eu and .de are not in IANA's RDAP bootstrap. Lookups therefore try RDAP where the TLD publishes a server and fall back to WHOIS on port 43, found via IANA's own referral, parsing the expiry line out of the free-text answer. Only the expiry date and registrar are read or stored. Verified live against the real registries: .se and .nu via WHOIS, .com/.org/.dev via RDAP. Behaviour worth knowing: - A DNS zone that is a subdomain (lab.example.se) resolves to the registration that actually expires by trying the name and then its parents, so no public-suffix list is needed. Zones already covered by a tracked domain are not looked up again. - "Couldn't ask" is never confused with "not registered": network errors, rate limits and garbled answers are errors, and a transient error at any level stops the walk from concluding the domain doesn't exist. - A failed refresh keeps the last known expiry and records why, rather than blanking a date that's still relied on. - Zones that don't resolve to a real registration (.lan, .local, unregistered names) simply get no row. Zone-derived rows disappear when their zone does; manual rows stay. Zone-derived rows can't be deleted by hand. - Registries that don't publish an expiry (.de, .eu) are tracked with a note instead of a date. - Input like "example.com/path" is refused rather than silently reduced to its host. - Runs on the daily secret-expiry schedule and reminder time, on demand (Check all now / per domain), and once at startup if nothing has been read in a day. Never blocks startup, one lookup at a time with a pause. The warning window lives with the other thresholds in settings. New table domains (migration 0011); two settings fields (toggle and warning days). Verified with 90 checks against fake RDAP/WHOIS backends (name normalization, date formats, WHOIS parsing including rate-limit and no-expiry answers, bootstrap and referral caching, stale-cache fallback, parent walking, add/sync/check/refresh, concurrency guard, alert selection and stale detection, the daily notification and its toggle, role rules) plus a live smoke test against real registries and a browser check of the page against the real router. Real dev database mtime untouched. Not checked: a screenshot of the finished page (the capture timed out); structure, sorting, errors and the viewer view were verified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
import { useEffect, useState, type ReactNode } from "react";
|
|
import { Routes, Route, Navigate } from "react-router-dom";
|
|
import { api, type CurrentUser, type UserRole, UnauthorizedError } from "./api/client";
|
|
import Login from "./pages/Login";
|
|
import Dashboard from "./pages/Dashboard";
|
|
import Users from "./pages/Users";
|
|
import Sessions from "./pages/Sessions";
|
|
import AuditLog from "./pages/AuditLog";
|
|
import DiagLog from "./pages/DiagLog";
|
|
import Secrets from "./pages/Secrets";
|
|
import Ipam from "./pages/Ipam";
|
|
import Dns from "./pages/Dns";
|
|
import Servers from "./pages/Servers";
|
|
import ServerDetail from "./pages/ServerDetail";
|
|
import Integrations from "./pages/Integrations";
|
|
import Docker from "./pages/Docker";
|
|
import Tailscale from "./pages/Tailscale";
|
|
import Semaphore from "./pages/Semaphore";
|
|
import Gitea from "./pages/Gitea";
|
|
import Proxmox from "./pages/Proxmox";
|
|
import Synology from "./pages/Synology";
|
|
import Generator from "./pages/Generator";
|
|
import Maintenance from "./pages/Maintenance";
|
|
import Domains from "./pages/Domains";
|
|
import Settings from "./pages/Settings";
|
|
import NotificationSettings from "./pages/settings/NotificationSettings";
|
|
import BadgeSettings from "./pages/settings/BadgeSettings";
|
|
import DisplaySettings from "./pages/settings/DisplaySettings";
|
|
import CacheSettings from "./pages/settings/CacheSettings";
|
|
import LogSettings from "./pages/settings/LogSettings";
|
|
import BackupSettings from "./pages/settings/BackupSettings";
|
|
import AppShell from "./layout/AppShell";
|
|
import { setDateTimeSettings } from "./utils/date";
|
|
import { setPageSize } from "./utils/pageSize";
|
|
|
|
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
|
|
|
function RequireRole({
|
|
user,
|
|
minRole,
|
|
children,
|
|
}: {
|
|
user: CurrentUser;
|
|
minRole: UserRole;
|
|
children: ReactNode;
|
|
}) {
|
|
if (roleRank[user.role] < roleRank[minRole]) {
|
|
return (
|
|
<div className="alert alert-danger">You don't have permission to view this page.</div>
|
|
);
|
|
}
|
|
return <>{children}</>;
|
|
}
|
|
|
|
export default function App() {
|
|
const [user, setUser] = useState<CurrentUser | null | undefined>(undefined);
|
|
|
|
useEffect(() => {
|
|
api.me().then(
|
|
(res) => setUser(res.user),
|
|
(err) => {
|
|
if (!(err instanceof UnauthorizedError)) {
|
|
console.error(err);
|
|
}
|
|
setUser(null);
|
|
},
|
|
);
|
|
api.settings.display().then((res) => {
|
|
setDateTimeSettings(res.display);
|
|
setPageSize(res.display.pageSize);
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
if (user === undefined) {
|
|
return <div className="loading-screen">Loading…</div>;
|
|
}
|
|
|
|
if (user === null) {
|
|
return <Login />;
|
|
}
|
|
|
|
return (
|
|
<AppShell user={user}>
|
|
<Routes>
|
|
<Route path="/" element={<Dashboard user={user} />} />
|
|
<Route path="/servers" element={<Servers user={user} />} />
|
|
<Route path="/servers/:id" element={<ServerDetail user={user} />} />
|
|
<Route path="/dns" element={<Dns user={user} />} />
|
|
<Route path="/ipam" element={<Ipam user={user} />} />
|
|
<Route path="/secrets" element={<Secrets user={user} />} />
|
|
<Route path="/integrations" element={<Integrations user={user} />} />
|
|
<Route path="/generator" element={<Generator />} />
|
|
<Route path="/domains" element={<Domains user={user} />} />
|
|
<Route path="/maintenance" element={<Maintenance user={user} />} />
|
|
<Route path="/docker" element={<Docker user={user} />} />
|
|
<Route path="/tailscale" element={<Tailscale user={user} />} />
|
|
<Route path="/semaphore" element={<Semaphore user={user} />} />
|
|
<Route path="/gitea" element={<Gitea user={user} />} />
|
|
<Route path="/proxmox" element={<Proxmox user={user} />} />
|
|
<Route path="/synology" element={<Synology user={user} />} />
|
|
<Route
|
|
path="/users"
|
|
element={
|
|
<RequireRole user={user} minRole="admin">
|
|
<Users />
|
|
</RequireRole>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/sessions"
|
|
element={
|
|
<RequireRole user={user} minRole="admin">
|
|
<Sessions />
|
|
</RequireRole>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/audit-log"
|
|
element={
|
|
<RequireRole user={user} minRole="operator">
|
|
<AuditLog />
|
|
</RequireRole>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/diag-log"
|
|
element={
|
|
<RequireRole user={user} minRole="admin">
|
|
<DiagLog />
|
|
</RequireRole>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/settings"
|
|
element={
|
|
<RequireRole user={user} minRole="admin">
|
|
<Settings />
|
|
</RequireRole>
|
|
}
|
|
>
|
|
<Route index element={<Navigate to="notifications" replace />} />
|
|
<Route path="notifications" element={<NotificationSettings />} />
|
|
<Route path="badges" element={<BadgeSettings />} />
|
|
<Route path="display" element={<DisplaySettings />} />
|
|
<Route path="cache" element={<CacheSettings />} />
|
|
<Route path="logs" element={<LogSettings />} />
|
|
<Route path="backup" element={<BackupSettings />} />
|
|
</Route>
|
|
</Routes>
|
|
</AppShell>
|
|
);
|
|
}
|