Scaffold Homelab Manager foundation

Monorepo (Express+TS+Drizzle/libSQL server, React+Vite+Tabler web) matching
the stack used by ScheduleTaskManager and Sloth Manager. Includes Authentik
OIDC login with local admin/operator/viewer roles (first user becomes admin),
a generalized audit log, encrypted-at-rest storage for future integration API
tokens, the DB schema for all planned modules, and the Tabler-styled app
shell/nav. Also ports the Secrets (expiry tracker) and IP Addresses (IPAM)
modules from Sloth Manager onto the new stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-14 21:57:13 +02:00
commit 6bd2ed52c1
52 files changed
+8103

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Homelab Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tabler/core": "^1.5.1",
"@tabler/icons-react": "^3.46.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useState, type ReactNode } from "react";
import { Routes, Route } 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 AuditLog from "./pages/AuditLog";
import Secrets from "./pages/Secrets";
import Ipam from "./pages/Ipam";
import ComingSoon from "./pages/ComingSoon";
import AppShell from "./layout/AppShell";
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);
},
);
}, []);
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={<ComingSoon title="Servers & Tasks" />} />
<Route path="/dns" element={<ComingSoon title="DNS" />} />
<Route path="/ipam" element={<Ipam user={user} />} />
<Route path="/secrets" element={<Secrets user={user} />} />
<Route path="/integrations" element={<ComingSoon title="Integrations" />} />
<Route
path="/users"
element={
<RequireRole user={user} minRole="admin">
<Users />
</RequireRole>
}
/>
<Route
path="/audit-log"
element={
<RequireRole user={user} minRole="operator">
<AuditLog />
</RequireRole>
}
/>
<Route
path="/settings"
element={
<RequireRole user={user} minRole="admin">
<ComingSoon title="Settings" />
</RequireRole>
}
/>
</Routes>
</AppShell>
);
}
+134
View File
@@ -0,0 +1,134 @@
export type UserRole = "admin" | "operator" | "viewer";
export interface CurrentUser {
id: number;
sub: string;
email?: string;
name?: string;
role: UserRole;
}
export interface UserRecord {
id: number;
oidcSub: string;
email: string | null;
name: string | null;
role: UserRole;
createdAt: string;
lastLoginAt: string | null;
}
export interface AuditLogEntry {
id: number;
actorUserId: number | null;
actorLabel: string | null;
category: string;
action: string;
targetType: string | null;
targetId: string | null;
detail: string | null;
createdAt: string;
}
export type SecretType = "api_token" | "ssl_certificate" | "password" | "generic";
export type SecretStatus = "ok" | "expiring" | "expired";
export interface SecretRecord {
id: number;
name: string;
type: SecretType;
description: string | null;
expiryDate: string;
warnDays: number;
notes: string | null;
createdAt: string;
updatedAt: string;
status: SecretStatus;
daysLeft: number;
}
export interface SecretInput {
name: string;
type: SecretType;
description?: string;
expiryDate: string;
warnDays: number;
notes?: string;
}
export interface IpamEntry {
id: number;
ipAddress: string;
label: string | null;
vendor: string | null;
location: string | null;
notes: string | null;
createdAt: string;
updatedAt: string;
matchingDnsRecords: string[];
}
export interface IpamInput {
ipAddress: string;
label?: string;
vendor?: string;
location?: string;
notes?: string;
}
export class UnauthorizedError extends Error {}
export class ForbiddenError extends Error {}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
credentials: "same-origin",
});
if (res.status === 401) {
throw new UnauthorizedError("unauthorized");
}
if (res.status === 403) {
throw new ForbiddenError("forbidden");
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Request failed (${res.status}): ${body}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
me: () => request<{ user: CurrentUser }>("/api/me"),
users: {
list: () => request<{ users: UserRecord[] }>("/api/users"),
updateRole: (id: number, role: UserRole) =>
request<{ user: UserRecord }>(`/api/users/${id}/role`, {
method: "PATCH",
body: JSON.stringify({ role }),
}),
},
auditLog: {
list: (limit = 200) => request<{ entries: AuditLogEntry[] }>(`/api/audit-log?limit=${limit}`),
},
secrets: {
list: () => request<{ secrets: SecretRecord[] }>("/api/secrets"),
create: (data: SecretInput) =>
request<{ secret: SecretRecord }>("/api/secrets", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: Partial<SecretInput>) =>
request<{ secret: SecretRecord }>(`/api/secrets/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
}),
remove: (id: number) => request<void>(`/api/secrets/${id}`, { method: "DELETE" }),
},
ipam: {
list: () => request<{ entries: IpamEntry[] }>("/api/ipam"),
create: (data: IpamInput) =>
request<{ entry: IpamEntry }>("/api/ipam", { method: "POST", body: JSON.stringify(data) }),
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) =>
request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
},
};
+103
View File
@@ -0,0 +1,103 @@
import { useState, type ReactNode } from "react";
import { NavLink } from "react-router-dom";
import {
IconLayoutDashboard,
IconServer2,
IconWorld,
IconNetwork,
IconKey,
IconPlugConnected,
IconUsers,
IconHistory,
IconSettings,
IconMenu2,
} from "@tabler/icons-react";
import type { CurrentUser } from "../api/client";
interface NavItem {
to: string;
label: string;
icon: ReactNode;
minRole?: "operator" | "admin";
}
const NAV_ITEMS: NavItem[] = [
{ to: "/", label: "Dashboard", icon: <IconLayoutDashboard size={20} /> },
{ to: "/servers", label: "Servers & Tasks", icon: <IconServer2 size={20} /> },
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
];
const roleRank: Record<CurrentUser["role"], number> = { viewer: 0, operator: 1, admin: 2 };
export default function AppShell({ user, children }: { user: CurrentUser; children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const visibleItems = NAV_ITEMS.filter(
(item) => !item.minRole || roleRank[user.role] >= roleRank[item.minRole],
);
return (
<div className="page">
<aside className="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
<div className="container-fluid">
<button
type="button"
className="navbar-toggler"
onClick={() => setSidebarOpen((open) => !open)}
aria-label="Toggle navigation"
>
<IconMenu2 size={20} />
</button>
<h1 className="navbar-brand navbar-brand-autodark">
<NavLink to="/">Homelab Manager</NavLink>
</h1>
<div className={`navbar-collapse collapse ${sidebarOpen ? "show" : ""}`}>
<ul className="navbar-nav pt-lg-3">
{visibleItems.map((item) => (
<li className="nav-item" key={item.to}>
<NavLink
to={item.to}
end={item.to === "/"}
className="nav-link"
onClick={() => setSidebarOpen(false)}
>
<span className="nav-link-icon d-md-none d-lg-inline-block">{item.icon}</span>
<span className="nav-link-title">{item.label}</span>
</NavLink>
</li>
))}
</ul>
</div>
</div>
</aside>
<div className="page-wrapper">
<div className="page-header d-print-none">
<div className="container-xl">
<div className="row g-2 align-items-center">
<div className="col">
<span className="text-secondary">
Signed in as {user.name ?? user.email ?? user.sub} · {user.role}
</span>
</div>
<div className="col-auto ms-auto d-print-none">
<a className="btn btn-outline-secondary btn-sm" href="/auth/logout">
Sign out
</a>
</div>
</div>
</div>
</div>
<div className="page-body">
<div className="container-xl">{children}</div>
</div>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "@tabler/core/dist/css/tabler.min.css";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { api, type AuditLogEntry } from "../api/client";
export default function AuditLog() {
const [entries, setEntries] = useState<AuditLogEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.auditLog
.list()
.then((res) => setEntries(res.entries))
.catch((err) => setError(String(err)));
}, []);
return (
<>
<h2 className="page-title mb-3">Audit Log</h2>
{error && <div className="alert alert-danger">{error}</div>}
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>When</th>
<th>Actor</th>
<th>Category</th>
<th>Action</th>
<th>Target</th>
</tr>
</thead>
<tbody>
{entries?.map((e) => (
<tr key={e.id}>
<td>{new Date(e.createdAt).toLocaleString()}</td>
<td>{e.actorLabel ?? "—"}</td>
<td>
<span className="badge bg-blue-lt">{e.category}</span>
</td>
<td>{e.action}</td>
<td>
{e.targetType ? `${e.targetType}${e.targetId ? ` #${e.targetId}` : ""}` : "—"}
</td>
</tr>
))}
{entries?.length === 0 && (
<tr>
<td colSpan={5} className="text-secondary text-center">
No activity recorded yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
export default function ComingSoon({ title }: { title: string }) {
return (
<>
<h2 className="page-title mb-3">{title}</h2>
<div className="card">
<div className="card-body text-secondary">
This module hasn't been built yet — it's on the roadmap for a follow-up pass.
</div>
</div>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { CurrentUser } from "../api/client";
export default function Dashboard({ user }: { user: CurrentUser }) {
return (
<>
<div className="row row-cards mb-3">
<div className="col-12">
<h2 className="page-title mb-3">Dashboard</h2>
<p className="text-secondary">
Welcome back, {user.name ?? user.email ?? user.sub}. Live status widgets for Proxmox,
Synology, Semaphore, Tailscale, Gitea, and Dockhand will appear here as each
integration is connected.
</p>
</div>
</div>
<div className="row row-cards">
{[
"Proxmox",
"Synology NAS",
"Semaphore",
"Tailscale",
"Gitea",
"Dockhand / Docker",
].map((name) => (
<div className="col-sm-6 col-lg-4" key={name}>
<div className="card">
<div className="card-body">
<div className="d-flex align-items-center">
<div className="subheader">{name}</div>
<div className="ms-auto">
<span className="badge bg-secondary-lt text-secondary">Not connected</span>
</div>
</div>
<div className="text-secondary mt-2">
Configure this integration from the Integrations page.
</div>
</div>
</div>
</div>
))}
</div>
</>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { useEffect, useMemo, useState } from "react";
import { api, type CurrentUser, type IpamEntry, type IpamInput } from "../api/client";
import { downloadCsv } from "../utils/csv";
const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" };
function isIpv6(ip: string) {
return ip.includes(":");
}
export default function Ipam({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator";
const [entries, setEntries] = useState<IpamEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<IpamEntry | null>(null);
const [adding, setAdding] = useState(false);
const [form, setForm] = useState<IpamInput>(emptyForm);
const [saving, setSaving] = useState(false);
function load() {
api.ipam
.list()
.then((res) => setEntries(res.entries))
.catch((err) => setError(String(err)));
}
useEffect(load, []);
const filtered = useMemo(() => {
if (!entries) return [];
const q = search.trim().toLowerCase();
if (!q) return entries;
return entries.filter((e) =>
[e.ipAddress, e.label, e.vendor, e.location].some((v) => (v ?? "").toLowerCase().includes(q)),
);
}, [entries, search]);
function startAdd() {
setAdding(true);
setEditing(null);
setForm(emptyForm);
}
function startEdit(entry: IpamEntry) {
setEditing(entry);
setAdding(false);
setForm({
ipAddress: entry.ipAddress,
label: entry.label ?? "",
vendor: entry.vendor ?? "",
location: entry.location ?? "",
notes: entry.notes ?? "",
});
}
function cancelForm() {
setAdding(false);
setEditing(null);
setForm(emptyForm);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSaving(true);
try {
if (editing) {
const { ipAddress: _ip, ...rest } = form;
await api.ipam.update(editing.id, rest);
} else {
await api.ipam.create(form);
}
cancelForm();
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function remove(entry: IpamEntry) {
if (!confirm(`Delete IP address "${entry.ipAddress}"?`)) return;
setError(null);
try {
await api.ipam.remove(entry.id);
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
function exportCsv() {
downloadCsv(
"ip-addresses.csv",
["IP Address", "Label", "Vendor", "Location", "Notes", "DNS Records"],
filtered.map((e) => [
e.ipAddress,
e.label ?? "",
e.vendor ?? "",
e.location ?? "",
e.notes ?? "",
e.matchingDnsRecords.join("; "),
]),
);
}
const showForm = adding || editing;
return (
<>
<h2 className="page-title mb-3">IP Addresses</h2>
{error && <div className="alert alert-danger">{error}</div>}
{canEdit && showForm && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">{editing ? `Edit ${editing.ipAddress}` : "Add an IP address"}</h3>
</div>
<form onSubmit={submit}>
<div className="card-body row g-3">
<div className="col-md-3">
<label className="form-label">IP address</label>
<input
className="form-control"
required
disabled={!!editing}
placeholder="203.0.113.10 or 2001:db8::1"
value={form.ipAddress}
onChange={(e) => setForm({ ...form, ipAddress: e.target.value })}
/>
{!editing && <div className="form-hint">Cannot be changed after creation.</div>}
</div>
<div className="col-md-3">
<label className="form-label">Label</label>
<input
className="form-control"
placeholder="Web Server 1"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
/>
</div>
<div className="col-md-2">
<label className="form-label">Vendor / provider</label>
<input
className="form-control"
placeholder="Hetzner"
value={form.vendor}
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
/>
</div>
<div className="col-md-2">
<label className="form-label">Location</label>
<input
className="form-control"
placeholder="Frankfurt"
value={form.location}
onChange={(e) => setForm({ ...form, location: e.target.value })}
/>
</div>
<div className="col-md-2">
<label className="form-label">Notes</label>
<input
className="form-control"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</div>
</div>
<div className="card-footer d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{editing ? "Save changes" : "Add IP address"}
</button>
<button type="button" className="btn" onClick={cancelForm}>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="card">
<div className="card-header">
<div className="d-flex flex-wrap gap-2 align-items-center w-100">
<input
className="form-control"
style={{ maxWidth: 280 }}
placeholder="Search IP, label, vendor, location…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{canEdit && !showForm && (
<button className="btn btn-primary" onClick={startAdd}>
Add IP address
</button>
)}
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
Export CSV
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>IP address</th>
<th>Label</th>
<th>Vendor</th>
<th>Location</th>
<th>DNS records</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{filtered.map((entry) => (
<tr key={entry.id}>
<td>
<span className={`badge ${isIpv6(entry.ipAddress) ? "bg-purple-lt" : "bg-blue-lt"} me-2`}>
{isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"}
</span>
{entry.ipAddress}
</td>
<td>{entry.label ?? "—"}</td>
<td>{entry.vendor ?? "—"}</td>
<td>{entry.location ?? "—"}</td>
<td className="text-secondary">
{entry.matchingDnsRecords.length > 0 ? entry.matchingDnsRecords.join(", ") : "—"}
</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm" onClick={() => startEdit(entry)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(entry)}>
Delete
</button>
</div>
</td>
)}
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
No IP addresses tracked yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
export default function Login() {
return (
<div className="login-screen">
<div className="login-card">
<h1>Homelab Manager</h1>
<p>Sign in with your homelab identity provider to continue.</p>
<a className="btn btn-primary w-100" href="/auth/login">
Sign in with Authentik
</a>
</div>
</div>
);
}
+283
View File
@@ -0,0 +1,283 @@
import { useEffect, useMemo, useState } from "react";
import { api, type CurrentUser, type SecretInput, type SecretRecord, type SecretStatus } from "../api/client";
import { downloadCsv } from "../utils/csv";
const TYPE_LABELS: Record<SecretRecord["type"], string> = {
api_token: "API Token",
ssl_certificate: "SSL Certificate",
password: "Password",
generic: "Generic",
};
const STATUS_BADGE: Record<SecretStatus, string> = {
ok: "bg-green-lt text-green",
expiring: "bg-yellow-lt text-yellow",
expired: "bg-red-lt text-red",
};
const emptyForm: SecretInput = {
name: "",
type: "generic",
description: "",
expiryDate: "",
warnDays: 30,
notes: "",
};
export default function Secrets({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator";
const [secrets, setSecrets] = useState<SecretRecord[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | SecretStatus>("all");
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<SecretInput>(emptyForm);
const [saving, setSaving] = useState(false);
function load() {
api.secrets
.list()
.then((res) => setSecrets(res.secrets))
.catch((err) => setError(String(err)));
}
useEffect(load, []);
const filtered = useMemo(() => {
if (!secrets) return [];
return secrets.filter((s) => {
if (statusFilter !== "all" && s.status !== statusFilter) return false;
const q = search.trim().toLowerCase();
if (!q) return true;
return s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
});
}, [secrets, search, statusFilter]);
function startEdit(s: SecretRecord) {
setEditingId(s.id);
setForm({
name: s.name,
type: s.type,
description: s.description ?? "",
expiryDate: s.expiryDate,
warnDays: s.warnDays,
notes: s.notes ?? "",
});
}
function cancelEdit() {
setEditingId(null);
setForm(emptyForm);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setSaving(true);
try {
if (editingId) {
await api.secrets.update(editingId, form);
} else {
await api.secrets.create(form);
}
cancelEdit();
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
async function remove(s: SecretRecord) {
if (!confirm(`Delete secret "${s.name}"?`)) return;
setError(null);
try {
await api.secrets.remove(s.id);
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
function exportCsv() {
downloadCsv(
"secrets.csv",
["Name", "Type", "Description", "Expiry Date", "Warn Days", "Status", "Days Left", "Notes"],
filtered.map((s) => [
s.name,
TYPE_LABELS[s.type],
s.description ?? "",
s.expiryDate,
s.warnDays,
s.status,
s.daysLeft,
s.notes ?? "",
]),
);
}
return (
<>
<h2 className="page-title mb-3">Secrets</h2>
{error && <div className="alert alert-danger">{error}</div>}
{canEdit && (
<div className="card mb-3">
<div className="card-header">
<h3 className="card-title">{editingId ? "Edit secret" : "Add a secret"}</h3>
</div>
<form onSubmit={submit}>
<div className="card-body row g-3">
<div className="col-md-4">
<label className="form-label">Name</label>
<input
className="form-control"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="col-md-3">
<label className="form-label">Type</label>
<select
className="form-select"
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value as SecretInput["type"] })}
>
{Object.entries(TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="col-md-2">
<label className="form-label">Expiry date</label>
<input
type="date"
className="form-control"
required
value={form.expiryDate}
onChange={(e) => setForm({ ...form, expiryDate: e.target.value })}
/>
</div>
<div className="col-md-3">
<label className="form-label">Warn (days before)</label>
<input
type="number"
min={0}
className="form-control"
value={form.warnDays}
onChange={(e) => setForm({ ...form, warnDays: Number(e.target.value) })}
/>
</div>
<div className="col-md-6">
<label className="form-label">Description</label>
<input
className="form-control"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div className="col-md-6">
<label className="form-label">Notes</label>
<input
className="form-control"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</div>
</div>
<div className="card-footer d-flex gap-2">
<button type="submit" className="btn btn-primary" disabled={saving}>
{editingId ? "Save changes" : "Add secret"}
</button>
{editingId && (
<button type="button" className="btn" onClick={cancelEdit}>
Cancel
</button>
)}
</div>
</form>
</div>
)}
<div className="card">
<div className="card-header">
<div className="d-flex flex-wrap gap-2 align-items-center w-100">
<input
className="form-control"
style={{ maxWidth: 260 }}
placeholder="Search name or description…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select
className="form-select"
style={{ maxWidth: 160 }}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
>
<option value="all">All statuses</option>
<option value="ok">OK</option>
<option value="expiring">Expiring</option>
<option value="expired">Expired</option>
</select>
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
Export CSV
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Expiry</th>
<th>Days left</th>
<th>Status</th>
<th>Description</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{filtered.map((s) => (
<tr key={s.id}>
<td>{s.name}</td>
<td>{TYPE_LABELS[s.type]}</td>
<td>{s.expiryDate}</td>
<td>{s.daysLeft < 0 ? `${Math.abs(s.daysLeft)}d ago` : `${s.daysLeft}d`}</td>
<td>
<span className={`badge ${STATUS_BADGE[s.status]}`}>{s.status}</span>
</td>
<td className="text-secondary">{s.description ?? "—"}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm" onClick={() => startEdit(s)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(s)}>
Delete
</button>
</div>
</td>
)}
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={canEdit ? 7 : 6} className="text-secondary text-center">
No secrets tracked yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import { api, type UserRecord, type UserRole } from "../api/client";
const ROLES: UserRole[] = ["viewer", "operator", "admin"];
export default function Users() {
const [users, setUsers] = useState<UserRecord[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [savingId, setSavingId] = useState<number | null>(null);
function load() {
api.users
.list()
.then((res) => setUsers(res.users))
.catch((err) => setError(String(err)));
}
useEffect(load, []);
async function changeRole(id: number, role: UserRole) {
setError(null);
setSavingId(id);
try {
await api.users.updateRole(id, role);
load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSavingId(null);
}
}
return (
<>
<h2 className="page-title mb-3">Users</h2>
{error && <div className="alert alert-danger">{error}</div>}
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Last login</th>
</tr>
</thead>
<tbody>
{users?.map((u) => (
<tr key={u.id}>
<td>{u.name ?? "—"}</td>
<td>{u.email ?? "—"}</td>
<td style={{ maxWidth: 160 }}>
<select
className="form-select form-select-sm"
value={u.role}
disabled={savingId === u.id}
onChange={(e) => changeRole(u.id, e.target.value as UserRole)}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</td>
<td>{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : "never"}</td>
</tr>
))}
{users?.length === 0 && (
<tr>
<td colSpan={4} className="text-secondary text-center">
No users yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: var(--tblr-secondary);
}
.login-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--tblr-bg-surface-secondary, #f4f6fb);
}
.login-card {
width: 100%;
max-width: 22rem;
padding: 2rem;
text-align: center;
background: var(--tblr-bg-surface, #fff);
border-radius: var(--tblr-border-radius, 4px);
box-shadow: var(--tblr-box-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.1));
}
.login-card h1 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.login-card p {
color: var(--tblr-secondary);
margin-bottom: 1.5rem;
}
+15
View File
@@ -0,0 +1,15 @@
function csvCell(value: unknown): string {
const s = value === null || value === undefined ? "" : String(value);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
export function downloadCsv(filename: string, headers: string[], rows: unknown[][]) {
const lines = [headers, ...rows].map((row) => row.map(csvCell).join(","));
const blob = new Blob([lines.join("\n")], { 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);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/layout/appshell.tsx","./src/pages/auditlog.tsx","./src/pages/comingsoon.tsx","./src/pages/dashboard.tsx","./src/pages/ipam.tsx","./src/pages/login.tsx","./src/pages/secrets.tsx","./src/pages/users.tsx","./src/utils/csv.ts"],"version":"5.9.3"}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3000",
"/auth": "http://localhost:3000",
"/health": "http://localhost:3000",
},
},
build: {
outDir: "dist",
},
});