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

+44
View File
@@ -0,0 +1,44 @@
import type { Request, Response, NextFunction } from "express";
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { users, type UserRole } from "../db/schema.js";
type CurrentUser = typeof users.$inferSelect;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
currentUser?: CurrentUser;
}
}
}
/** Requires a valid session AND a matching local user row; attaches req.currentUser. */
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const sessionUser = req.session.user;
if (!sessionUser) {
return res.status(401).json({ error: "unauthorized" });
}
const [user] = await db.select().from(users).where(eq(users.oidcSub, sessionUser.sub)).limit(1);
if (!user) {
return res.status(401).json({ error: "unauthorized" });
}
req.currentUser = user;
next();
}
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
/** Must run after requireAuth. Rejects unless the current user's role is >= minRole. */
export function requireRole(minRole: UserRole) {
return (req: Request, res: Response, next: NextFunction) => {
const user = req.currentUser;
if (!user || roleRank[user.role] < roleRank[minRole]) {
return res.status(403).json({ error: "forbidden" });
}
next();
};
}
+15
View File
@@ -0,0 +1,15 @@
import * as client from "openid-client";
import { env } from "../env.js";
let configPromise: Promise<client.Configuration> | null = null;
export function getOidcConfig(): Promise<client.Configuration> {
if (!configPromise) {
configPromise = client.discovery(
new URL(env.authentik.issuerUrl),
env.authentik.clientId,
env.authentik.clientSecret,
);
}
return configPromise;
}
+100
View File
@@ -0,0 +1,100 @@
import { Router } from "express";
import * as client from "openid-client";
import { getOidcConfig } from "./oidc.js";
import { upsertUserFromLogin } from "./users.js";
import { env } from "../env.js";
export const authRouter = Router();
authRouter.get("/login", async (req, res, next) => {
try {
const config = await getOidcConfig();
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
req.session.pendingAuth = { codeVerifier, state };
const redirectUri = new URL("/auth/callback", env.appBaseUrl).toString();
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: "openid email profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
});
req.session.save((err) => {
if (err) return next(err);
res.redirect(authUrl.href);
});
} catch (err) {
next(err);
}
});
authRouter.get("/callback", async (req, res, next) => {
try {
const pending = req.session.pendingAuth;
if (!pending) {
return res.status(400).send("Login session expired. Please try signing in again.");
}
const config = await getOidcConfig();
const currentUrl = new URL(req.originalUrl, env.appBaseUrl);
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: pending.codeVerifier,
expectedState: pending.state,
});
const claims = tokens.claims();
if (!claims?.sub) {
return res.status(400).send("Identity provider did not return a valid identity.");
}
let email: string | undefined = typeof claims.email === "string" ? claims.email : undefined;
let name: string | undefined = typeof claims.name === "string" ? claims.name : undefined;
try {
const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub);
email = userinfo.email ?? email;
name = userinfo.name ?? userinfo.preferred_username ?? name;
} catch {
// fall back to ID token claims already captured above
}
await upsertUserFromLogin({ sub: claims.sub, email, name });
delete req.session.pendingAuth;
req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token };
req.session.save((err) => {
if (err) return next(err);
res.redirect("/");
});
} catch (err) {
next(err);
}
});
authRouter.get("/logout", async (req, res, next) => {
const idToken = req.session.user?.idToken;
try {
const config = await getOidcConfig();
let endSessionUrl: URL | undefined;
try {
endSessionUrl = client.buildEndSessionUrl(config, {
post_logout_redirect_uri: env.appBaseUrl,
...(idToken ? { id_token_hint: idToken } : {}),
});
} catch {
// Provider doesn't advertise RP-Initiated Logout; just clear our own session.
}
req.session.destroy((err) => {
if (err) return next(err);
res.redirect(endSessionUrl ? endSessionUrl.href : "/");
});
} catch (err) {
next(err);
}
});
+45
View File
@@ -0,0 +1,45 @@
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
/**
* Called on every successful OIDC login. The very first user ever to sign in
* becomes admin; everyone after defaults to viewer until an admin promotes
* them from the Users page.
*/
export async function upsertUserFromLogin(params: {
sub: string;
email?: string;
name?: string;
}) {
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
const now = new Date().toISOString();
if (existing) {
const [updated] = await db
.update(users)
.set({
email: params.email ?? existing.email,
name: params.name ?? existing.name,
lastLoginAt: now,
})
.where(eq(users.id, existing.id))
.returning();
return updated;
}
const anyUser = await db.select({ id: users.id }).from(users).limit(1);
const role = anyUser.length === 0 ? ("admin" as const) : ("viewer" as const);
const [created] = await db
.insert(users)
.values({
oidcSub: params.sub,
email: params.email,
name: params.name,
role,
lastLoginAt: now,
})
.returning();
return created;
}