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

+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" }),
},
};