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:
commit
6606420b4a
43 files changed
+5577
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user