Initial commit: everythingbutwork MVP
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled

Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets)
und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion),
Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check
und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0.

Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 +
Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv).

Planungs-Artefakte unter _bmad-output/planning-artifacts/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.7 committed 2026-05-25 22:47:26 +02:00
commit 4f33509a0f
131 files changed
+34339

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
// Fastify-Decorator: macht `req.cockpitUser` verfügbar, wenn die Session gültig ist.
// Die Typaugmentation lebt in `../types.ts`, damit sie auch in tRPC-Imports
// gezogen wird (web → api Type-Only-Import).
import '../types.js';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { loadEnv } from '../env.js';
import { getSessionWithUser } from './session.js';
export async function registerAuthMiddleware(app: FastifyInstance): Promise<void> {
const env = loadEnv();
app.decorateRequest('cockpitUser', null);
app.decorateRequest('cockpitSessionId', null);
app.addHook('onRequest', async (req: FastifyRequest) => {
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
if (!sessionId) return;
const found = await getSessionWithUser(sessionId);
if (found) {
req.cockpitUser = found.user;
req.cockpitSessionId = sessionId;
}
});
}
+92
View File
@@ -0,0 +1,92 @@
// Authentik OIDC via openid-client v6. Authorization Code + PKCE.
import * as oidc from 'openid-client';
import { loadEnv } from '../env.js';
import { logger } from '../lib/logger.js';
let cachedConfig: oidc.Configuration | undefined;
export async function getOidcConfig(): Promise<oidc.Configuration> {
if (cachedConfig) return cachedConfig;
const env = loadEnv();
cachedConfig = await oidc.discovery(
new URL(env.AUTHENTIK_ISSUER),
env.AUTHENTIK_CLIENT_ID,
env.AUTHENTIK_CLIENT_SECRET,
);
logger.info({ issuer: env.AUTHENTIK_ISSUER }, 'oidc discovery completed');
return cachedConfig;
}
export interface PendingFlow {
state: string;
nonce: string;
codeVerifier: string;
returnTo: string;
}
export async function buildAuthorizationUrl(returnTo: string): Promise<{
url: URL;
pending: PendingFlow;
}> {
const config = await getOidcConfig();
const env = loadEnv();
const codeVerifier = oidc.randomPKCECodeVerifier();
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
const state = oidc.randomState();
const nonce = oidc.randomNonce();
const url = oidc.buildAuthorizationUrl(config, {
redirect_uri: env.AUTHENTIK_REDIRECT_URI,
scope: 'openid profile email groups',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
nonce,
});
return {
url,
pending: { state, nonce, codeVerifier, returnTo },
};
}
export interface OidcUserInfo {
sub: string;
email: string;
name: string;
groups: string[];
}
export async function completeAuthorization(
callbackUrl: URL,
pending: PendingFlow,
): Promise<OidcUserInfo> {
const config = await getOidcConfig();
const tokens = await oidc.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: pending.codeVerifier,
expectedState: pending.state,
expectedNonce: pending.nonce,
idTokenExpected: true,
});
const claims = tokens.claims();
if (!claims) {
throw new Error('OIDC-Token enthält keine Claims.');
}
const sub = String(claims.sub ?? '');
const email = String(claims.email ?? '');
const name = String(claims.name ?? claims.preferred_username ?? email);
const rawGroups = claims.groups;
const groups: string[] = Array.isArray(rawGroups)
? rawGroups.filter((g): g is string => typeof g === 'string')
: [];
if (!sub || !email) {
throw new Error('OIDC-Claims unvollständig (sub oder email fehlt).');
}
return { sub, email, name, groups };
}
+141
View File
@@ -0,0 +1,141 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { loadEnv } from '../env.js';
import { logger } from '../lib/logger.js';
import { buildAuthorizationUrl, completeAuthorization, type PendingFlow } from './oidc.js';
import { createSession, deleteSession } from './session.js';
import { upsertUserFromOidc } from './user.js';
const PENDING_COOKIE = 'cockpit_oidc_pending';
function setSessionCookie(reply: FastifyReply, sessionId: string): void {
const env = loadEnv();
const maxAge = env.SESSION_TTL_HOURS * 60 * 60;
reply.setCookie(env.SESSION_COOKIE_NAME, sessionId, {
path: '/',
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge,
signed: false,
});
}
function clearSessionCookie(reply: FastifyReply): void {
const env = loadEnv();
reply.clearCookie(env.SESSION_COOKIE_NAME, { path: '/' });
}
function setPendingCookie(reply: FastifyReply, pending: PendingFlow): void {
reply.setCookie(PENDING_COOKIE, JSON.stringify(pending), {
path: '/',
httpOnly: true,
secure: loadEnv().NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 10 * 60, // 10 Minuten für den Round-Trip
signed: true,
});
}
function readPendingCookie(req: FastifyRequest): PendingFlow | null {
const raw = req.cookies[PENDING_COOKIE];
if (!raw) return null;
const unsigned = req.unsignCookie(raw);
if (!unsigned.valid || !unsigned.value) return null;
try {
const parsed = JSON.parse(unsigned.value) as PendingFlow;
return parsed;
} catch {
return null;
}
}
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
const env = loadEnv();
// Startet den OIDC-Flow.
app.get('/api/auth/login', async (req, reply) => {
const returnTo =
typeof req.query === 'object' && req.query !== null && 'returnTo' in req.query
? String((req.query as Record<string, unknown>).returnTo ?? '/puls')
: '/puls';
const { url, pending } = await buildAuthorizationUrl(returnTo);
setPendingCookie(reply, pending);
return reply.redirect(url.toString(), 302);
});
// OIDC-Callback. Tauscht den Code gegen Tokens und legt eine Session an.
app.get('/api/auth/callback', async (req, reply) => {
const pending = readPendingCookie(req);
if (!pending) {
logger.warn('callback ohne pending-cookie');
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=session_expired`, 302);
}
try {
const callbackUrl = new URL(env.AUTHENTIK_REDIRECT_URI);
for (const [k, v] of Object.entries(req.query as Record<string, string>)) {
callbackUrl.searchParams.set(k, v);
}
const oidcUser = await completeAuthorization(callbackUrl, pending);
const userRow = await upsertUserFromOidc(oidcUser);
const newSession = await createSession({
userId: userRow.id,
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});
setSessionCookie(reply, newSession.id);
reply.clearCookie(PENDING_COOKIE, { path: '/' });
const target = pending.returnTo.startsWith('/') ? pending.returnTo : '/puls';
return reply.redirect(`${env.PUBLIC_BASE_URL}${target}`, 302);
} catch (err) {
logger.error({ err }, 'oidc callback failed');
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=oidc_failed`, 302);
}
});
app.post('/api/auth/logout', async (req, reply) => {
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
if (sessionId) {
await deleteSession(sessionId);
}
clearSessionCookie(reply);
return { ok: true };
});
// Dev-Auto-Login: legt sofort eine Session für den Admin-Dejan an, sodass
// die App ohne Authentik durchnavigierbar ist. Authentik kommt in der späteren
// Roadmap. Nur aktiv bei NODE_ENV=development.
if (env.NODE_ENV === 'development') {
const handler = async (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => {
const { getDb } = await import('../db.js');
const { user } = await import('@cockpit/db');
const { eq } = await import('drizzle-orm');
const { db } = getDb();
const found = (await db.select().from(user).where(eq(user.email, 'dejan@tool-ctrl.de')).limit(1))[0];
const userRow = found
? found
: await upsertUserFromOidc({
sub: 'local-dejan',
email: 'dejan@tool-ctrl.de',
name: 'Dejan Cosic',
groups: ['entwicklung'],
});
const newSession = await createSession({
userId: userRow.id,
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});
setSessionCookie(reply, newSession.id);
return reply.redirect(`${env.PUBLIC_BASE_URL}/puls`, 302);
};
app.get('/api/auth/dev-login', handler);
app.post('/api/auth/dev-login', handler);
}
}
+63
View File
@@ -0,0 +1,63 @@
// Session-Store in Postgres. Browser sieht nur `session_id` als HttpOnly-Cookie.
import { eq, lt } from 'drizzle-orm';
import { session, user, type Session, type User } from '@cockpit/db';
import { getDb } from '../db.js';
import { loadEnv } from '../env.js';
export interface CreateSessionInput {
userId: string;
ipAddress?: string;
userAgent?: string;
}
export async function createSession(input: CreateSessionInput): Promise<Session> {
const env = loadEnv();
const { db } = getDb();
const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 60 * 60 * 1000);
const [created] = await db
.insert(session)
.values({
userId: input.userId,
expiresAt,
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
})
.returning();
if (!created) {
throw new Error('Session konnte nicht angelegt werden.');
}
return created;
}
export async function getSessionWithUser(
sessionId: string,
): Promise<{ session: Session; user: User } | null> {
const { db } = getDb();
const rows = await db
.select({ session, user })
.from(session)
.innerJoin(user, eq(session.userId, user.id))
.where(eq(session.id, sessionId))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() <= Date.now()) {
await db.delete(session).where(eq(session.id, sessionId));
return null;
}
return row;
}
export async function deleteSession(sessionId: string): Promise<void> {
const { db } = getDb();
await db.delete(session).where(eq(session.id, sessionId));
}
export async function pruneExpiredSessions(): Promise<number> {
const { db } = getDb();
const result = await db.delete(session).where(lt(session.expiresAt, new Date()));
return result.rowCount ?? 0;
}
+37
View File
@@ -0,0 +1,37 @@
import { eq } from 'drizzle-orm';
import { user, type User } from '@cockpit/db';
import { getDb } from '../db.js';
import type { OidcUserInfo } from './oidc.js';
// MVP: Group-Claim → default_area_id wird in Story 1.x mit echter Area-Tabelle
// gemappt. Aktuell speichern wir den User mit `default_area_id = null` und
// pflegen den Bereich später manuell. (OQ-1.)
export async function upsertUserFromOidc(info: OidcUserInfo): Promise<User> {
const { db } = getDb();
const existing = await db.select().from(user).where(eq(user.authentikSub, info.sub)).limit(1);
if (existing[0]) {
const [updated] = await db
.update(user)
.set({
name: info.name,
email: info.email,
updatedAt: new Date(),
})
.where(eq(user.id, existing[0].id))
.returning();
if (!updated) throw new Error('User-Update fehlgeschlagen.');
return updated;
}
const [created] = await db
.insert(user)
.values({
authentikSub: info.sub,
name: info.name,
email: info.email,
})
.returning();
if (!created) throw new Error('User-Anlage fehlgeschlagen.');
return created;
}