Handy-Insel Portal: Startseite, Kopfleiste und Authentik-Anbindung

- Portal mit Kacheln aus Authentik (pro Benutzer gefiltert) und "Aktuelles"
- Web-Component <hi-topbar> als zentraler App-Umschalter für alle Tools
- OIDC-Login über Authentik, zentrales Abmelden
- Setup-Skript für Authentik (Gruppen-Bindungen, Portal-App, Service-Account)
- Deployment auf den Portal-CT per systemd

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kerimatilganandClaude Opus 5.5 committed 2026-09-27 12:18:32 +02:00
commit 6606420b4a
43 files changed
+5577

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
import { Router, type NextFunction, type Request, type Response } from 'express';
import { config, isAllowedUrl } from '../config.js';
import { appsForUser } from '../authentik.js';
import { db, type NewsRow } from '../db.js';
import type { PortalUser } from '../types.js';
export const apiRouter = Router();
function requireUser(req: Request, res: Response, next: NextFunction) {
if (!req.session.user) return res.status(401).json({ error: 'nicht angemeldet', login: `${config.publicUrl}/auth/login` });
next();
}
function isEditor(user: PortalUser): boolean {
return user.groups.some((g) => config.editorGroups.includes(g));
}
function requireEditor(req: Request, res: Response, next: NextFunction) {
if (!req.session.user || !isEditor(req.session.user)) return res.status(403).json({ error: 'keine Berechtigung' });
next();
}
function publicUser(user: PortalUser) {
const initials = user.name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]!.toUpperCase())
.join('');
return {
username: user.username,
name: user.name,
firstName: user.name.split(/\s+/)[0] ?? user.name,
email: user.email,
initials: initials || user.username.slice(0, 2).toUpperCase(),
groups: user.groups,
canEditNews: isEditor(user),
};
}
function links() {
return {
portal: `${config.publicUrl}/`,
account: `${config.authentik.url}/if/user/#/settings`,
logout: `${config.publicUrl}/auth/logout`,
login: `${config.publicUrl}/auth/login`,
};
}
apiRouter.get('/me', requireUser, (req, res) => {
res.json({ user: publicUser(req.session.user!), links: links() });
});
apiRouter.get('/apps', requireUser, async (req, res) => {
try {
res.json({ apps: await appsForUser(req.session.user!) });
} catch (err) {
console.error('[apps]', err);
res.status(502).json({ error: 'Apps konnten nicht von Authentik geladen werden', apps: [] });
}
});
/**
* Navigation für die Kopfleiste in den einzelnen Tools (topbar.js).
* Wird per CORS mit Cookies von *.handy-insel.de aufgerufen.
*/
apiRouter.options('/navigation', navigationCors, (_req, res) => res.sendStatus(204));
apiRouter.get('/navigation', navigationCors, async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
const user = req.session.user;
if (!user) return res.status(401).json({ user: null, apps: [], links: links() });
let apps: Awaited<ReturnType<typeof appsForUser>> = [];
try {
apps = await appsForUser(user);
} catch (err) {
console.error('[navigation]', err);
}
res.json({ user: publicUser(user), apps, links: links() });
});
function navigationCors(req: Request, res: Response, next: NextFunction) {
const origin = req.headers.origin;
if (origin && isAllowedUrl(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Max-Age', '600');
res.setHeader('Vary', 'Origin');
}
next();
}
// ---- Aktuelles ----
function toNews(row: NewsRow) {
return {
id: row.id,
title: row.title,
body: row.body,
pinned: !!row.pinned,
author: row.author,
createdAt: `${row.created_at.replace(' ', 'T')}Z`,
updatedAt: `${row.updated_at.replace(' ', 'T')}Z`,
};
}
function parseNews(body: unknown): { title: string; body: string; pinned: boolean } | null {
if (!body || typeof body !== 'object') return null;
const b = body as Record<string, unknown>;
const title = typeof b.title === 'string' ? b.title.trim() : '';
const text = typeof b.body === 'string' ? b.body.trim() : '';
if (!title || title.length > 200 || text.length > 10_000) return null;
return { title, body: text, pinned: b.pinned === true };
}
apiRouter.get('/news', requireUser, (req, res) => {
const limit = Math.min(Number(req.query.limit) || 20, 100);
const rows = db
.prepare<[number], NewsRow>('SELECT * FROM news ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ?')
.all(limit);
res.json({ news: rows.map(toNews) });
});
apiRouter.post('/news', requireEditor, (req, res) => {
const input = parseNews(req.body);
if (!input) return res.status(400).json({ error: 'Titel fehlt oder Text zu lang' });
const info = db
.prepare('INSERT INTO news (title, body, pinned, author) VALUES (?, ?, ?, ?)')
.run(input.title, input.body, input.pinned ? 1 : 0, req.session.user!.name);
const row = db.prepare<[number | bigint], NewsRow>('SELECT * FROM news WHERE id = ?').get(info.lastInsertRowid)!;
res.status(201).json(toNews(row));
});
apiRouter.put('/news/:id', requireEditor, (req, res) => {
const input = parseNews(req.body);
if (!input) return res.status(400).json({ error: 'Titel fehlt oder Text zu lang' });
const info = db
.prepare("UPDATE news SET title = ?, body = ?, pinned = ?, updated_at = datetime('now') WHERE id = ?")
.run(input.title, input.body, input.pinned ? 1 : 0, Number(req.params.id));
if (!info.changes) return res.status(404).json({ error: 'nicht gefunden' });
const row = db.prepare<[number], NewsRow>('SELECT * FROM news WHERE id = ?').get(Number(req.params.id))!;
res.json(toNews(row));
});
apiRouter.delete('/news/:id', requireEditor, (req, res) => {
const info = db.prepare('DELETE FROM news WHERE id = ?').run(Number(req.params.id));
if (!info.changes) return res.status(404).json({ error: 'nicht gefunden' });
res.sendStatus(204);
});
+66
View File
@@ -0,0 +1,66 @@
import { Router } from 'express';
import { config, isAllowedUrl } from '../config.js';
import { buildLoginUrl, buildLogoutUrl, handleCallback } from '../oidc.js';
export const authRouter = Router();
/** Rücksprung nur auf das Portal selbst oder erlaubte Tool-Domains. */
function safeReturnTo(raw: unknown): string {
if (typeof raw !== 'string' || !raw) return '/';
if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
return isAllowedUrl(raw) ? raw : '/';
}
authRouter.get('/login', async (req, res, next) => {
try {
const returnTo = safeReturnTo(req.query.returnTo);
if (config.devUser) {
req.session.user = {
sub: `dev-${config.devUser}`,
username: config.devUser,
name: config.devUser.charAt(0).toUpperCase() + config.devUser.slice(1),
email: `${config.devUser}@example.invalid`,
groups: ['HI-Mitarbeiter', 'HI-Admin'],
};
return res.redirect(returnTo);
}
const { url, state, codeVerifier } = await buildLoginUrl();
req.session.login = { state, codeVerifier, returnTo };
req.session.save((err) => (err ? next(err) : res.redirect(url)));
} catch (err) {
next(err);
}
});
authRouter.get('/callback', async (req, res) => {
const pending = req.session.login;
if (!pending) return res.redirect('/auth/login');
try {
const query = req.originalUrl.slice(req.originalUrl.indexOf('?'));
const { user, idToken } = await handleCallback(query, pending);
const returnTo = pending.returnTo;
// Neue Session-ID nach dem Login (Schutz vor Session Fixation).
req.session.regenerate((err) => {
if (err) return res.status(500).send('Anmeldung fehlgeschlagen.');
req.session.user = user;
req.session.idToken = idToken;
req.session.save(() => res.redirect(returnTo));
});
} catch (err) {
console.error('[auth] Callback fehlgeschlagen:', err);
delete req.session.login;
res.status(401).send('Anmeldung fehlgeschlagen. <a href="/auth/login">Erneut versuchen</a>');
}
});
authRouter.get('/logout', async (req, res, next) => {
try {
const url = await buildLogoutUrl(req.session.idToken);
req.session.destroy(() => {
res.clearCookie('hi.portal.sid');
res.redirect(url);
});
} catch (err) {
next(err);
}
});