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

+98
View File
@@ -0,0 +1,98 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from './config.js';
import type { PortalUser } from './types.js';
export interface PortalApp {
slug: string;
name: string;
url: string;
description: string;
section: string;
iconUrl: string;
newTab: boolean;
}
interface AkApplication {
slug: string;
name: string;
launch_url: string | null;
meta_launch_url: string;
meta_description: string;
meta_icon: string | null;
meta_icon_url?: string | null;
meta_hide?: boolean;
group: string;
open_in_new_tab: boolean;
}
const CACHE_MS = 60_000;
// Mitgelieferte Icons (Build: dist/public/icons, Entwicklung: public/icons).
const here = path.dirname(fileURLToPath(import.meta.url));
const iconDir = [path.resolve(here, '../public/icons'), path.resolve(here, '../../public/icons')].find((d) => fs.existsSync(d));
const bundledIcons = new Set(iconDir ? fs.readdirSync(iconDir).map((f) => f.replace(/\.svg$/, '')) : []);
const cache = new Map<number, { at: number; apps: PortalApp[] }>();
async function ak<T>(path: string): Promise<T> {
const res = await fetch(`${config.authentik.url}/api/v3${path}`, {
headers: { Authorization: `Bearer ${config.authentik.token}`, Accept: 'application/json' },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`Authentik ${path}: HTTP ${res.status}`);
return (await res.json()) as T;
}
async function resolvePk(user: PortalUser): Promise<number | undefined> {
if (user.authentikPk) return user.authentikPk;
const res = await ak<{ results: { pk: number; username: string }[] }>(
`/core/users/?username=${encodeURIComponent(user.username)}`,
);
const match = res.results.find((u) => u.username === user.username);
if (match) user.authentikPk = match.pk;
return match?.pk;
}
function iconFor(app: AkApplication): string {
// Eigenes Icon in Authentik hat Vorrang, sonst das mitgelieferte Portal-Icon.
const custom = app.meta_icon_url || app.meta_icon;
if (custom) return custom.startsWith('http') ? custom : `${config.authentik.url}${custom}`;
return `${config.publicUrl}/icons/${bundledIcons.has(app.slug) ? app.slug : 'default'}.svg`;
}
function sortApps(apps: PortalApp[]): PortalApp[] {
const rank = (slug: string) => {
const i = config.appOrder.indexOf(slug);
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
return apps.sort((a, b) => rank(a.slug) - rank(b.slug) || a.name.localeCompare(b.name, 'de'));
}
/** Liefert die Apps, auf die der Benutzer laut Authentik-Richtlinien Zugriff hat. */
export async function appsForUser(user: PortalUser): Promise<PortalApp[]> {
if (!config.authentik.token) return [];
const pk = await resolvePk(user);
if (!pk) return [];
const hit = cache.get(pk);
if (hit && Date.now() - hit.at < CACHE_MS) return hit.apps;
const res = await ak<{ results: AkApplication[] }>(`/core/applications/?for_user=${pk}&page_size=200`);
const apps = sortApps(
res.results
.filter((a) => !a.meta_hide && !config.hiddenApps.includes(a.slug))
.map((a) => ({ app: a, url: a.launch_url || a.meta_launch_url }))
.filter(({ url }) => !!url && /^https?:\/\//.test(url))
.map(({ app, url }) => ({
slug: app.slug,
name: app.name,
url,
description: app.meta_description || '',
section: app.group || 'Anwendungen',
iconUrl: iconFor(app),
newTab: app.open_in_new_tab,
})),
);
cache.set(pk, { at: Date.now(), apps });
return apps;
}
+82
View File
@@ -0,0 +1,82 @@
import path from 'node:path';
function env(name: string, fallback = ''): string {
return (process.env[name] ?? fallback).trim();
}
function list(name: string, fallback: string): string[] {
return env(name, fallback).split(',').map((s) => s.trim()).filter(Boolean);
}
const isProduction = env('NODE_ENV') === 'production';
const publicUrl = env('PUBLIC_URL', 'http://localhost:3010').replace(/\/$/, '');
export const config = {
isProduction,
port: Number(env('PORT', '3010')),
host: env('HOST', '0.0.0.0'),
publicUrl,
dataDir: path.resolve(env('DATA_DIR', './data')),
sessionSecret: env('SESSION_SECRET'),
/** Sitzungsdauer in Stunden (rollierend). */
sessionHours: Number(env('SESSION_HOURS', '12')),
oidc: {
issuer: env('OIDC_ISSUER'),
clientId: env('OIDC_CLIENT_ID'),
clientSecret: env('OIDC_CLIENT_SECRET'),
redirectUri: env('OIDC_REDIRECT_URI', `${publicUrl}/auth/callback`),
},
authentik: {
/** Basis-URL von Authentik, z.B. https://login.handy-insel.de */
url: env('AUTHENTIK_URL', 'https://login.handy-insel.de').replace(/\/$/, ''),
/** Token eines Service-Accounts mit Recht "view_user" + "view_user_applications". */
token: env('AUTHENTIK_TOKEN'),
},
/** Gruppen, die Neuigkeiten unter "Aktuelles" schreiben dürfen. */
editorGroups: list('EDITOR_GROUPS', 'HI-Admin,HI-Leitung'),
/** Apps, die nie angezeigt werden (Slugs), z.B. das Portal selbst. */
hiddenApps: list('HIDDEN_APPS', 'portal'),
/** Reihenfolge der Kacheln (Slugs); nicht genannte Apps folgen alphabetisch. */
appOrder: list(
'APP_ORDER',
'preisliste,vvl,zeiterfassung,timer,kundenkarte,datenuebertragungen,service,paperless,patch-mon,preisliste-dev,zeit-dev',
),
/**
* Domains, von denen aus die Kopfleiste (topbar.js) die Navigation laden darf
* und auf die nach dem Login zurückgeleitet werden darf.
*/
allowedDomains: list('ALLOWED_DOMAINS', 'handy-insel.de'),
/** Nur für lokale Entwicklung: Login überspringen und als dieser Benutzer anmelden. */
devUser: isProduction ? '' : env('DEV_USER'),
};
export function assertConfig(): void {
const missing: string[] = [];
if (!config.sessionSecret) missing.push('SESSION_SECRET');
if (!config.devUser) {
if (!config.oidc.issuer) missing.push('OIDC_ISSUER');
if (!config.oidc.clientId) missing.push('OIDC_CLIENT_ID');
if (!config.oidc.clientSecret) missing.push('OIDC_CLIENT_SECRET');
}
if (!config.authentik.token) console.warn('[config] AUTHENTIK_TOKEN fehlt – es werden keine Apps angezeigt.');
if (missing.length) {
throw new Error(`Fehlende Umgebungsvariablen: ${missing.join(', ')}`);
}
}
/** Prüft, ob eine URL/Origin zu einer erlaubten Domain gehört (exakt oder Subdomain, nur https außer lokal). */
export function isAllowedUrl(raw: string): boolean {
let url: URL;
try {
url = new URL(raw);
} catch {
return false;
}
if (!config.isProduction && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) return true;
if (url.protocol !== 'https:') return false;
return config.allowedDomains.some((d) => url.hostname === d || url.hostname.endsWith(`.${d}`));
}
+38
View File
@@ -0,0 +1,38 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { config } from './config.js';
fs.mkdirSync(config.dataDir, { recursive: true });
export const db = new Database(path.join(config.dataDir, 'portal.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expires ON sessions (expires);
CREATE TABLE IF NOT EXISTS news (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
pinned INTEGER NOT NULL DEFAULT 0,
author TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
export interface NewsRow {
id: number;
title: string;
body: string;
pinned: number;
author: string;
created_at: string;
updated_at: string;
}
+87
View File
@@ -0,0 +1,87 @@
import './types.js';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express, { type NextFunction, type Request, type Response } from 'express';
import session from 'express-session';
import helmet from 'helmet';
import { assertConfig, config } from './config.js';
import { initOidc } from './oidc.js';
import { SqliteStore } from './sessionStore.js';
import { authRouter } from './routes/auth.js';
import { apiRouter } from './routes/api.js';
assertConfig();
const here = path.dirname(fileURLToPath(import.meta.url));
// Im Build liegt das Frontend unter dist/public, der Server unter dist/server.
const publicDir = path.resolve(here, '../public');
const app = express();
app.set('trust proxy', 1);
app.disable('x-powered-by');
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
imgSrc: ["'self'", 'data:', config.authentik.url],
styleSrc: ["'self'", "'unsafe-inline'"],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
},
},
// topbar.js und die Icons werden von den anderen Tools geladen.
crossOriginResourcePolicy: { policy: 'same-site' },
}),
);
const ttlMs = config.sessionHours * 60 * 60 * 1000;
app.use(
session({
name: 'hi.portal.sid',
secret: config.sessionSecret,
store: new SqliteStore(ttlMs),
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
httpOnly: true,
// Lax: wird auch bei Aufrufen von anderen *.handy-insel.de-Tools (same-site) mitgeschickt.
sameSite: 'lax',
secure: config.isProduction,
maxAge: ttlMs,
},
}),
);
app.get('/health', (_req, res) => res.json({ ok: true }));
app.use(express.json({ limit: '100kb' }));
app.use('/auth', authRouter);
app.use('/api', apiRouter);
// Öffentliche Assets für die Tools (Kopfleiste + Icons) – ohne Login erreichbar.
app.get('/topbar.js', (_req, res) => {
res.setHeader('Cache-Control', 'public, max-age=300');
res.sendFile(path.join(publicDir, 'topbar.js'));
});
app.use(express.static(publicDir, { index: false, maxAge: '1h' }));
// Portal-Seite: nur für angemeldete Benutzer, sonst direkt zu Authentik.
app.get('*', (req, res) => {
if (!req.session.user) return res.redirect(`/auth/login?returnTo=${encodeURIComponent(req.originalUrl)}`);
res.setHeader('Cache-Control', 'no-store');
res.sendFile(path.join(publicDir, 'index.html'));
});
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error('[error]', err);
res.status(500).json({ error: 'Interner Fehler' });
});
await initOidc();
app.listen(config.port, config.host, () => {
console.log(`[portal] läuft auf http://${config.host}:${config.port} (${config.publicUrl})`);
if (config.devUser) console.warn(`[portal] DEV-Modus: automatisch angemeldet als "${config.devUser}"`);
});
+76
View File
@@ -0,0 +1,76 @@
import * as oidc from 'openid-client';
import { config } from './config.js';
import type { PortalUser } from './types.js';
let oidcConfig: oidc.Configuration | null = null;
/** Lädt die OIDC-Konfiguration von Authentik; bei Fehlern wird beim nächsten Login erneut versucht. */
async function getConfig(): Promise<oidc.Configuration> {
if (!oidcConfig) {
oidcConfig = await oidc.discovery(new URL(config.oidc.issuer), config.oidc.clientId, config.oidc.clientSecret);
}
return oidcConfig;
}
export async function initOidc(): Promise<void> {
if (config.devUser) return;
try {
await getConfig();
console.log('[oidc] Discovery erfolgreich:', config.oidc.issuer);
} catch (err) {
console.error('[oidc] Discovery fehlgeschlagen, wird beim ersten Login wiederholt:', err);
}
}
export async function buildLoginUrl(): Promise<{ url: string; state: string; codeVerifier: string }> {
const cfg = await getConfig();
const codeVerifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(cfg, {
redirect_uri: config.oidc.redirectUri,
scope: 'openid profile email',
code_challenge: await oidc.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256',
state,
});
return { url: url.href, state, codeVerifier };
}
/** Tauscht den Code gegen Tokens und liefert den Benutzer aus dem ID-Token. */
export async function handleCallback(
query: string,
expected: { state: string; codeVerifier: string },
): Promise<{ user: PortalUser; idToken?: string }> {
const cfg = await getConfig();
// Hinter dem Reverse-Proxy die öffentliche Callback-URL rekonstruieren.
const currentUrl = new URL(config.oidc.redirectUri);
currentUrl.search = query;
const tokens = await oidc.authorizationCodeGrant(cfg, currentUrl, {
pkceCodeVerifier: expected.codeVerifier,
expectedState: expected.state,
});
const claims = tokens.claims();
if (!claims) throw new Error('Kein ID-Token erhalten');
const str = (v: unknown) => (typeof v === 'string' ? v : '');
const username = str(claims.preferred_username) || str(claims.email) || claims.sub;
return {
user: {
sub: claims.sub,
username,
name: str(claims.name) || username,
email: str(claims.email),
groups: Array.isArray(claims.groups) ? claims.groups.filter((g): g is string => typeof g === 'string') : [],
},
idToken: tokens.id_token,
};
}
/** URL zum Abmelden bei Authentik (beendet die SSO-Sitzung für alle Tools). */
export async function buildLogoutUrl(idToken?: string): Promise<string> {
if (config.devUser) return '/';
const cfg = await getConfig();
return oidc.buildEndSessionUrl(cfg, {
...(idToken ? { id_token_hint: idToken } : {}),
post_logout_redirect_uri: `${config.publicUrl}/`,
}).href;
}
+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);
}
});
+54
View File
@@ -0,0 +1,54 @@
import session from 'express-session';
import { db } from './db.js';
/** Minimaler express-session-Store auf Basis der Portal-SQLite-Datenbank. */
export class SqliteStore extends session.Store {
private getStmt = db.prepare<[string, number], { data: string }>('SELECT data FROM sessions WHERE sid = ? AND expires > ?');
private setStmt = db.prepare('INSERT OR REPLACE INTO sessions (sid, data, expires) VALUES (?, ?, ?)');
private delStmt = db.prepare('DELETE FROM sessions WHERE sid = ?');
private touchStmt = db.prepare('UPDATE sessions SET expires = ? WHERE sid = ?');
constructor(private ttlMs: number) {
super();
const cleanup = () => db.prepare('DELETE FROM sessions WHERE expires <= ?').run(Date.now());
cleanup();
setInterval(cleanup, 60 * 60 * 1000).unref();
}
private expiry(sess: session.SessionData): number {
const exp = sess.cookie?.expires ? new Date(sess.cookie.expires).getTime() : NaN;
return Number.isFinite(exp) ? exp : Date.now() + this.ttlMs;
}
get(sid: string, cb: (err: unknown, session?: session.SessionData | null) => void): void {
try {
const row = this.getStmt.get(sid, Date.now());
cb(null, row ? JSON.parse(row.data) : null);
} catch (err) {
cb(err);
}
}
set(sid: string, sess: session.SessionData, cb?: (err?: unknown) => void): void {
try {
this.setStmt.run(sid, JSON.stringify(sess), this.expiry(sess));
cb?.();
} catch (err) {
cb?.(err);
}
}
destroy(sid: string, cb?: (err?: unknown) => void): void {
try {
this.delStmt.run(sid);
cb?.();
} catch (err) {
cb?.(err);
}
}
touch(sid: string, sess: session.SessionData, cb?: () => void): void {
this.touchStmt.run(this.expiry(sess), sid);
cb?.();
}
}
+19
View File
@@ -0,0 +1,19 @@
import 'express-session';
export interface PortalUser {
sub: string;
username: string;
name: string;
email: string;
groups: string[];
/** Primärschlüssel des Benutzers in Authentik (für die App-Abfrage), wird nachgeladen. */
authentikPk?: number;
}
declare module 'express-session' {
interface SessionData {
user?: PortalUser;
idToken?: string;
login?: { state: string; codeVerifier: string; returnTo: string };
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "../dist/server",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src"]
}