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>
No files matched your search
@@ -0,0 +1,25 @@
|
|||||||
|
# Öffentliche Adresse des Portals (ohne / am Ende)
|
||||||
|
PUBLIC_URL=https://portal.handy-insel.de
|
||||||
|
PORT=3010
|
||||||
|
HOST=0.0.0.0
|
||||||
|
NODE_ENV=production
|
||||||
|
DATA_DIR=/var/lib/hi-portal
|
||||||
|
# Zufällig erzeugen: openssl rand -hex 32
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# Authentik OIDC-Provider "Portal" (confidential, Redirect: https://portal.handy-insel.de/auth/callback)
|
||||||
|
OIDC_ISSUER=https://login.handy-insel.de/application/o/portal/
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Authentik-API: Token eines Service-Accounts, der Apps pro Benutzer abfragen darf
|
||||||
|
AUTHENTIK_URL=https://login.handy-insel.de
|
||||||
|
AUTHENTIK_TOKEN=
|
||||||
|
|
||||||
|
# Wer darf unter "Aktuelles" schreiben (Authentik-Gruppen, kommagetrennt)
|
||||||
|
EDITOR_GROUPS=HI-Admin,HI-Leitung
|
||||||
|
# Domains, von denen die Kopfleiste geladen werden darf / auf die nach Login zurückgeleitet wird
|
||||||
|
ALLOWED_DOMAINS=handy-insel.de
|
||||||
|
# Optional: Kachel-Reihenfolge (Slugs) und ausgeblendete Apps
|
||||||
|
# APP_ORDER=preisliste,vvl,zeiterfassung,timer,kundenkarte,datenuebertragungen,service,paperless,patch-mon
|
||||||
|
# HIDDEN_APPS=portal
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Handy-Insel Portal
|
||||||
|
|
||||||
|
Zentrale Startseite für alle internen Tools der Handy-Insel, nach dem Vorbild von openDesk:
|
||||||
|
|
||||||
|
- **Portal** (`https://portal.handy-insel.de`): Begrüßung, Kacheln aller Tools, für die man freigeschaltet ist, und „Aktuelles“ für interne Ankündigungen.
|
||||||
|
- **Kopfleiste `<hi-topbar>`**: App-Umschalter (9-Punkte-Menü), Benutzermenü und Abmelden. Jedes Tool bindet sie mit einer Zeile ein, dadurch fühlt sich alles wie ein System an.
|
||||||
|
- **Authentik** (`https://login.handy-insel.de`) bleibt die einzige Quelle für Benutzer, Gruppen und Rechte. Das Portal fragt Authentik, welche Apps ein Benutzer sehen darf. Wer in Authentik keinen Zugriff auf eine App hat, sieht auch keine Kachel.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────── Authentik (SSO, Gruppen, App-Rechte) ───────────┐
|
||||||
|
Browser ─► Pangolin ─┬─► portal.handy-insel.de (Kacheln, Aktuelles, /api/navigation, /topbar.js)
|
||||||
|
├─► preis. / vvl. / zeit. / timer. / time. / data. / service.
|
||||||
|
│ └─ laden https://portal.handy-insel.de/topbar.js
|
||||||
|
└─► …
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rechte (Authentik-Gruppen)
|
||||||
|
|
||||||
|
| Gruppe | Mitglieder | sieht zusätzlich |
|
||||||
|
|---|---|---|
|
||||||
|
| `HI-Mitarbeiter` | alle Mitarbeiter | Preisliste, VVL, Zeiterfassung, Timer, Kundenkarte, Datenübertragungen, Service, Portal |
|
||||||
|
| `HI-Leitung` | Hubert | Paperless; darf „Aktuelles“ schreiben |
|
||||||
|
| `HI-Admin` | Kerim, Michi | Paperless, PatchMon, Dev-Instanzen; darf „Aktuelles“ schreiben |
|
||||||
|
|
||||||
|
Die Zuordnung *App → Gruppen* steht in `scripts/authentik-setup.py` (`APP_ACCESS`) und wird damit in Authentik gesetzt.
|
||||||
|
Das Feld **Gruppe** einer Authentik-Application bestimmt den Bereich im Portal (leer = „Anwendungen“, z.B. „Administration“).
|
||||||
|
Eigene Icons können in Authentik hochgeladen werden, sonst nimmt das Portal seine Icons aus `public/icons/<slug>.svg`.
|
||||||
|
|
||||||
|
## Neues Tool ins Portal aufnehmen
|
||||||
|
|
||||||
|
1. In Authentik eine Application anlegen (Name, Slug, Launch-URL).
|
||||||
|
2. Slug in `APP_ACCESS` eintragen und `scripts/authentik-setup.py --apply` laufen lassen (oder die Gruppe in Authentik direkt binden).
|
||||||
|
3. Optional: Icon `public/icons/<slug>.svg` und Reihenfolge in `APP_ORDER`.
|
||||||
|
4. Kopfleiste im Tool einbinden (siehe unten).
|
||||||
|
|
||||||
|
## Kopfleiste in ein Tool einbauen
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://portal.handy-insel.de/topbar.js" data-app="vvl" defer></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `data-app`: Slug der Authentik-Application. Die App wird im Menü markiert und ihr Name neben dem Logo angezeigt.
|
||||||
|
- `data-sticky`: Die Leiste bleibt beim Scrollen oben.
|
||||||
|
- `data-auto="false"`: nicht automatisch einfügen, sondern selbst `<hi-topbar app="vvl"></hi-topbar>` platzieren.
|
||||||
|
- Die Leiste ist 44 px hoch und setzt `--hi-topbar-height` auf `<html>`. Layouts mit `height: 100vh` sollten `calc(100vh - var(--hi-topbar-height, 0px))` verwenden.
|
||||||
|
- Die Leiste nutzt Shadow DOM, CSS-Konflikte mit dem Tool sind also ausgeschlossen.
|
||||||
|
|
||||||
|
**Content-Security-Policy des Tools erweitern** (sonst blockiert der Browser die Leiste):
|
||||||
|
|
||||||
|
```
|
||||||
|
script-src … https://portal.handy-insel.de
|
||||||
|
connect-src … https://portal.handy-insel.de
|
||||||
|
img-src … https://portal.handy-insel.de https://login.handy-insel.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Ist man im Tool angemeldet, im Portal aber nicht, zeigt das Menü „Jetzt anmelden“. Weil die Authentik-Sitzung schon besteht, ist das ein unsichtbarer Redirect zurück ins Tool.
|
||||||
|
|
||||||
|
## Abmelden
|
||||||
|
|
||||||
|
„Abmelden“ im Portal oder in der Kopfleiste beendet die Portal-Sitzung **und die Authentik-Sitzung**. Die eigenen Sitzungen der Tools laufen bis zu ihrem Ablauf weiter. Für ein echtes Single-Logout braucht jedes Tool einen Backchannel-Logout-Endpunkt; das ist als nächster Ausbauschritt vorgesehen.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Host | CT `portal` (192.168.1.28), Debian 13 |
|
||||||
|
| Dienst | `systemctl status hi-portal`, Logs: `journalctl -u hi-portal -f` |
|
||||||
|
| Code | `/opt/hi-portal` |
|
||||||
|
| Konfiguration | `/etc/hi-portal/portal.env` (siehe `.env.example`) |
|
||||||
|
| Daten | `/var/lib/hi-portal/portal.db` (Sitzungen, Aktuelles) |
|
||||||
|
| Pangolin | `portal.handy-insel.de` → `http://192.168.1.28:3010`. **Pangolin-eigene Authentifizierung für diese Ressource aus**, das Portal macht den Login selbst. |
|
||||||
|
|
||||||
|
**Erstinstallation**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. CT vorbereiten (einmalig)
|
||||||
|
scp deploy/setup-ct.sh root@192.168.1.28: && ssh root@192.168.1.28 bash setup-ct.sh
|
||||||
|
# 2. Portal ausspielen
|
||||||
|
./deploy/deploy.sh root@192.168.1.28
|
||||||
|
# 3. Authentik einrichten (Probelauf, dann ausführen); schreibt Client-ID/Secret/Token in die portal.env
|
||||||
|
ssh root@192.168.1.28
|
||||||
|
AK_TOKEN=<admin-token> python3 /opt/hi-portal/scripts/authentik-setup.py
|
||||||
|
AK_TOKEN=<admin-token> python3 /opt/hi-portal/scripts/authentik-setup.py --apply --env /etc/hi-portal/portal.env
|
||||||
|
systemctl restart hi-portal
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update:** `./deploy/deploy.sh`
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
# Server ohne Authentik-Login (nur lokal, in production gesperrt):
|
||||||
|
NODE_ENV=development DEV_USER=kerim SESSION_SECRET=dev AUTHENTIK_TOKEN=<token> node dist/server/index.js
|
||||||
|
# Frontend mit Hot-Reload auf :5180 (leitet /api und /auth an :3010 weiter)
|
||||||
|
npm run dev:web
|
||||||
|
```
|
||||||
|
|
||||||
|
Aufbau: `server/` (Express + TypeScript, openid-client, SQLite), `frontend/` (React + Vite + Tailwind), `topbar/` (Web-Component, per esbuild gebaut), `public/` (Icons, Logo).
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Baut das Portal lokal und spielt es auf den Portal-CT aus.
|
||||||
|
# ./deploy/deploy.sh [root@192.168.1.28]
|
||||||
|
set -euo pipefail
|
||||||
|
TARGET="${1:-root@192.168.1.28}"
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
rsync -az --delete \
|
||||||
|
--include='/dist/***' --include='/scripts/***' --include='/deploy/***' \
|
||||||
|
--include='/package.json' --include='/package-lock.json' --exclude='*' \
|
||||||
|
./ "$TARGET:/opt/hi-portal/"
|
||||||
|
|
||||||
|
ssh "$TARGET" bash -s <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
cd /opt/hi-portal
|
||||||
|
npm ci --omit=dev --no-audit --no-fund
|
||||||
|
chown -R root:root /opt/hi-portal
|
||||||
|
install -m 644 deploy/hi-portal.service /etc/systemd/system/hi-portal.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable hi-portal >/dev/null
|
||||||
|
if grep -q '^OIDC_CLIENT_SECRET=.\+' /etc/hi-portal/portal.env; then
|
||||||
|
systemctl restart hi-portal
|
||||||
|
sleep 2
|
||||||
|
systemctl --no-pager --lines=5 status hi-portal
|
||||||
|
else
|
||||||
|
echo "Hinweis: /etc/hi-portal/portal.env ist noch nicht vollständig – Dienst nicht gestartet."
|
||||||
|
fi
|
||||||
|
REMOTE
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Handy-Insel Portal
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=hi-portal
|
||||||
|
Group=hi-portal
|
||||||
|
WorkingDirectory=/opt/hi-portal
|
||||||
|
EnvironmentFile=/etc/hi-portal/portal.env
|
||||||
|
ExecStart=/usr/bin/node dist/server/index.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ReadWritePaths=/var/lib/hi-portal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Einmalige Einrichtung des Portal-CT (Debian 13). Auf dem CT als root ausführen.
|
||||||
|
set -euo pipefail
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq nodejs npm rsync python3 ca-certificates >/dev/null
|
||||||
|
id hi-portal >/dev/null 2>&1 || useradd --system --home /var/lib/hi-portal --shell /usr/sbin/nologin hi-portal
|
||||||
|
install -d -o hi-portal -g hi-portal -m 750 /var/lib/hi-portal
|
||||||
|
install -d -m 755 /opt/hi-portal
|
||||||
|
install -d -m 750 /etc/hi-portal
|
||||||
|
if [ ! -f /etc/hi-portal/portal.env ]; then
|
||||||
|
cat > /etc/hi-portal/portal.env <<ENV
|
||||||
|
NODE_ENV=production
|
||||||
|
PUBLIC_URL=https://portal.handy-insel.de
|
||||||
|
PORT=3010
|
||||||
|
HOST=0.0.0.0
|
||||||
|
DATA_DIR=/var/lib/hi-portal
|
||||||
|
SESSION_SECRET=$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p -c 64)
|
||||||
|
OIDC_ISSUER=https://login.handy-insel.de/application/o/portal/
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
AUTHENTIK_URL=https://login.handy-insel.de
|
||||||
|
AUTHENTIK_TOKEN=
|
||||||
|
EDITOR_GROUPS=HI-Admin,HI-Leitung
|
||||||
|
ALLOWED_DOMAINS=handy-insel.de
|
||||||
|
ENV
|
||||||
|
chmod 640 /etc/hi-portal/portal.env
|
||||||
|
chgrp hi-portal /etc/hi-portal/portal.env
|
||||||
|
fi
|
||||||
|
echo "CT eingerichtet. Node: $(node -v)"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#2B0F71" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<title>Handy-Insel Portal</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { api, type App as PortalApp, type Links, type News, type NewsInput, type User } from './api';
|
||||||
|
import { Icon } from './icons';
|
||||||
|
|
||||||
|
function greeting(date = new Date()): string {
|
||||||
|
const h = date.getHours();
|
||||||
|
if (h < 11) return 'Guten Morgen';
|
||||||
|
if (h < 18) return 'Guten Tag';
|
||||||
|
return 'Guten Abend';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [me, setMe] = useState<{ user: User; links: Links } | null>(null);
|
||||||
|
const [apps, setApps] = useState<PortalApp[] | null>(null);
|
||||||
|
const [appsError, setAppsError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.me().then(setMe).catch(() => undefined);
|
||||||
|
api
|
||||||
|
.apps()
|
||||||
|
.then((r) => setApps(r.apps))
|
||||||
|
.catch((e: Error) => {
|
||||||
|
setApps([]);
|
||||||
|
setAppsError(e.message);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!me) return <div className="grid min-h-screen place-items-center text-sm text-slate-500">Lade Portal …</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<Header user={me.user} links={me.links} />
|
||||||
|
<main className="mx-auto grid w-full max-w-6xl flex-1 gap-12 px-4 pb-16 pt-10 sm:px-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] lg:pt-14">
|
||||||
|
<section>
|
||||||
|
<h1 className="text-3xl font-bold leading-tight text-ink sm:text-4xl">
|
||||||
|
{greeting()},
|
||||||
|
<br />
|
||||||
|
{me.user.firstName}.
|
||||||
|
</h1>
|
||||||
|
<AppSections apps={apps} error={appsError} />
|
||||||
|
</section>
|
||||||
|
<NewsPanel canEdit={me.user.canEditNews} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Kopfzeile */
|
||||||
|
|
||||||
|
function Header({ user, links }: { user: User; links: Links }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e: MouseEvent | KeyboardEvent) => {
|
||||||
|
if (e instanceof KeyboardEvent ? e.key === 'Escape' : !ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', close);
|
||||||
|
document.addEventListener('keydown', close);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', close);
|
||||||
|
document.removeEventListener('keydown', close);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-20 border-b border-slate-200 bg-white">
|
||||||
|
<div className="flex h-14 items-center justify-between px-4 sm:px-6">
|
||||||
|
<a href="/" className="flex items-center gap-2.5" aria-label="Handy-Insel Portal">
|
||||||
|
<img src="/icon-192.png" alt="" className="h-8 w-8" />
|
||||||
|
<span className="text-lg tracking-tight text-ink">
|
||||||
|
Handy-<span className="font-bold">Insel</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="grid h-9 w-9 place-items-center rounded-full border-2 border-slate-300 bg-slate-100 text-xs font-bold text-ink hover:border-brand-500"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
title={user.name}
|
||||||
|
>
|
||||||
|
{user.initials}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 mt-2 w-64 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="border-b border-slate-100 px-4 py-3">
|
||||||
|
<div className="font-semibold text-ink">{user.name}</div>
|
||||||
|
<div className="truncate text-sm text-slate-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
<a role="menuitem" href={links.account} className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-brand-50">
|
||||||
|
<Icon name="user" className="h-4 w-4 text-brand-600" /> Konto & Passwort
|
||||||
|
</a>
|
||||||
|
<a role="menuitem" href={links.logout} className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-brand-50">
|
||||||
|
<Icon name="logout" className="h-4 w-4 text-brand-600" /> Abmelden
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Anwendungen */
|
||||||
|
|
||||||
|
function AppSections({ apps, error }: { apps: PortalApp[] | null; error: string }) {
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
const map = new Map<string, PortalApp[]>();
|
||||||
|
for (const app of apps ?? []) map.set(app.section, [...(map.get(app.section) ?? []), app]);
|
||||||
|
return [...map.entries()];
|
||||||
|
}, [apps]);
|
||||||
|
|
||||||
|
if (apps === null) {
|
||||||
|
return (
|
||||||
|
<SectionTitle title="Anwendungen">
|
||||||
|
<div className="text-sm text-slate-500">Lade Anwendungen …</div>
|
||||||
|
</SectionTitle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!apps.length) {
|
||||||
|
return (
|
||||||
|
<SectionTitle title="Anwendungen">
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{error || 'Für dein Konto sind noch keine Anwendungen freigeschaltet. Bitte wende dich an einen Admin.'}
|
||||||
|
</p>
|
||||||
|
</SectionTitle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sections.map(([title, list]) => (
|
||||||
|
<SectionTitle key={title} title={title}>
|
||||||
|
<ul className="grid grid-cols-[repeat(auto-fill,minmax(88px,1fr))] gap-x-4 gap-y-5">
|
||||||
|
{list.map((app) => (
|
||||||
|
<li key={app.slug}>
|
||||||
|
<AppTile app={app} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</SectionTitle>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="mb-5 flex items-center gap-2 text-base font-semibold text-ink">
|
||||||
|
<Icon name="grid" className="h-4 w-4 text-slate-500" />
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppTile({ app }: { app: PortalApp }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={app.url}
|
||||||
|
target={app.newTab ? '_blank' : undefined}
|
||||||
|
rel={app.newTab ? 'noopener' : undefined}
|
||||||
|
title={app.description || app.name}
|
||||||
|
className="group flex flex-col items-center gap-2 text-center"
|
||||||
|
>
|
||||||
|
<span className="grid h-[72px] w-[72px] place-items-center rounded-xl border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)] transition group-hover:-translate-y-0.5 group-hover:border-brand-200 group-hover:shadow-md">
|
||||||
|
<img src={app.iconUrl} alt="" className="h-10 w-10 object-contain" loading="lazy" />
|
||||||
|
</span>
|
||||||
|
<span lang="de" className="w-full hyphens-auto break-words text-[13px] font-medium leading-tight text-ink">{app.name}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Aktuelles */
|
||||||
|
|
||||||
|
const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
|
||||||
|
function NewsPanel({ canEdit }: { canEdit: boolean }) {
|
||||||
|
const [news, setNews] = useState<News[] | null>(null);
|
||||||
|
const [editing, setEditing] = useState<News | 'new' | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const load = () =>
|
||||||
|
api
|
||||||
|
.news()
|
||||||
|
.then((r) => setNews(r.news))
|
||||||
|
.catch((e: Error) => setError(e.message));
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function save(input: NewsInput) {
|
||||||
|
if (editing === 'new') await api.createNews(input);
|
||||||
|
else if (editing) await api.updateNews(editing.id, input);
|
||||||
|
setEditing(null);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(item: News) {
|
||||||
|
if (!confirm(`„${item.title}“ wirklich löschen?`)) return;
|
||||||
|
await api.deleteNews(item.id).catch((e: Error) => alert(e.message));
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="lg:pt-[4.5rem]">
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
|
<h2 className="flex items-center gap-2 text-base font-semibold text-ink">
|
||||||
|
<Icon name="megaphone" className="h-4 w-4" />
|
||||||
|
Aktuelles
|
||||||
|
</h2>
|
||||||
|
{canEdit && !editing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing('new')}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-brand-500 bg-white px-3 py-1 text-xs font-semibold text-brand-600 hover:bg-brand-50"
|
||||||
|
>
|
||||||
|
<Icon name="plus" className="h-3.5 w-3.5" /> Neuigkeit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<NewsForm initial={editing === 'new' ? undefined : editing} onCancel={() => setEditing(null)} onSave={save} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
{news && !news.length && !editing && <p className="text-sm text-slate-600">Es gibt noch keine Neuigkeiten.</p>}
|
||||||
|
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{news?.map((item) => (
|
||||||
|
<li key={item.id} className="rounded-xl border border-slate-200 bg-white p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h3 className="font-semibold text-ink">
|
||||||
|
{item.pinned && <Icon name="pin" className="-mt-0.5 mr-1.5 inline h-3.5 w-3.5 text-brand-600" />}
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex shrink-0 gap-1">
|
||||||
|
<IconButton label="Bearbeiten" icon="pencil" onClick={() => setEditing(item)} />
|
||||||
|
<IconButton label="Löschen" icon="trash" onClick={() => remove(item)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-slate-500">
|
||||||
|
{dateFmt.format(new Date(item.createdAt))} · {item.author}
|
||||||
|
</div>
|
||||||
|
{item.body && <p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-slate-700">{item.body}</p>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({ label, icon, onClick }: { label: string; icon: 'pencil' | 'trash'; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className="grid h-7 w-7 place-items-center rounded-md text-slate-500 hover:bg-slate-100 hover:text-ink"
|
||||||
|
>
|
||||||
|
<Icon name={icon} className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewsForm({
|
||||||
|
initial,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: News;
|
||||||
|
onSave: (input: NewsInput) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState(initial?.title ?? '');
|
||||||
|
const [body, setBody] = useState(initial?.body ?? '');
|
||||||
|
const [pinned, setPinned] = useState(initial?.pinned ?? false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await onSave({ title, body, pinned });
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = 'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-brand-500 focus:outline-none';
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="mb-4 space-y-3 rounded-xl border border-brand-200 bg-white p-4">
|
||||||
|
<input className={field} placeholder="Titel" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={200} required autoFocus />
|
||||||
|
<textarea className={`${field} min-h-28`} placeholder="Text (optional)" value={body} onChange={(e) => setBody(e.target.value)} />
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} className="accent-brand-600" />
|
||||||
|
Oben anheften
|
||||||
|
</label>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={onCancel} className="rounded-md px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{initial ? 'Speichern' : 'Veröffentlichen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
firstName: string;
|
||||||
|
email: string;
|
||||||
|
initials: string;
|
||||||
|
groups: string[];
|
||||||
|
canEditNews: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Links {
|
||||||
|
portal: string;
|
||||||
|
account: string;
|
||||||
|
logout: string;
|
||||||
|
login: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface App {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
section: string;
|
||||||
|
iconUrl: string;
|
||||||
|
newTab: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface News {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
pinned: boolean;
|
||||||
|
author: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewsInput = Pick<News, 'title' | 'body' | 'pinned'>;
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = `/auth/login?returnTo=${encodeURIComponent(location.pathname)}`;
|
||||||
|
throw new Error('nicht angemeldet');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
throw new Error(body.error ?? `Fehler ${res.status}`);
|
||||||
|
}
|
||||||
|
return (res.status === 204 ? undefined : await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: () => request<{ user: User; links: Links }>('/api/me'),
|
||||||
|
apps: () => request<{ apps: App[] }>('/api/apps'),
|
||||||
|
news: () => request<{ news: News[] }>('/api/news'),
|
||||||
|
createNews: (input: NewsInput) => request<News>('/api/news', { method: 'POST', body: JSON.stringify(input) }),
|
||||||
|
updateNews: (id: number, input: NewsInput) =>
|
||||||
|
request<News>(`/api/news/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
|
||||||
|
deleteNews: (id: number) => request<void>(`/api/news/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
declare module "*.css";
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const paths = {
|
||||||
|
grid: 'M4 4h3v3H4zm6.5 0h3v3h-3zM17 4h3v3h-3zM4 10.5h3v3H4zm6.5 0h3v3h-3zm6.5 0h3v3h-3zM4 17h3v3H4zm6.5 0h3v3h-3zM17 17h3v3h-3z',
|
||||||
|
megaphone:
|
||||||
|
'M18 3v18l-6-3.6V20a1.5 1.5 0 0 1-3 0v-3.8H6a3 3 0 0 1-3-3v-2.4a3 3 0 0 1 3-3h5.2zm2.5 6.5a3 3 0 0 1 0 5z',
|
||||||
|
plus: 'M11 4h2v7h7v2h-7v7h-2v-7H4v-2h7z',
|
||||||
|
pin: 'M15 3l6 6-2 1-3.5 3.5L15 19l-2 2-4-4-5 5-1-1 5-5-4-4 2-2 5.5-.5L15 6z',
|
||||||
|
pencil: 'M4 17.3V20h2.7L17.8 8.9l-2.7-2.7zM20.7 6a1 1 0 0 0 0-1.4l-1.3-1.3a1 1 0 0 0-1.4 0l-1.1 1.1 2.7 2.7z',
|
||||||
|
trash: 'M9 3h6l1 2h4v2H4V5h4zm-3 6h12l-1 12H7z',
|
||||||
|
user: 'M12 12a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zm-8 9c0-4.4 3.6-7 8-7s8 2.6 8 7z',
|
||||||
|
logout: 'M10 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h5v-2H5V5h5zm6.6 4.4L15.2 8.8 17.4 11H9v2h8.4l-2.2 2.2 1.4 1.4L21.2 12z',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function Icon({ name, className }: { name: keyof typeof paths; className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
|
||||||
|
<path d={paths[name]} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './styles.css';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-brand-50: #f3f0fd;
|
||||||
|
--color-brand-100: #e6defb;
|
||||||
|
--color-brand-200: #cbbdf7;
|
||||||
|
--color-brand-500: #6a3de8;
|
||||||
|
--color-brand-600: #5a2fd6;
|
||||||
|
--color-brand-700: #4a22b8;
|
||||||
|
--color-brand-900: #2b0f71;
|
||||||
|
--color-ink: #1f2a52;
|
||||||
|
--color-canvas: #f4f4f5;
|
||||||
|
--font-sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-brand-500);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "handy-insel-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Handy-Insel Portal – zentrale Startseite und App-Umschalter für alle internen Tools (Authentik-SSO)",
|
||||||
|
"scripts": {
|
||||||
|
"dev:server": "tsx watch server/src/index.ts",
|
||||||
|
"dev:web": "vite",
|
||||||
|
"build": "npm run build:web && npm run build:topbar && npm run build:server",
|
||||||
|
"build:web": "vite build",
|
||||||
|
"build:topbar": "esbuild topbar/src/topbar.ts --bundle --minify --format=iife --target=es2019 --outfile=dist/public/topbar.js",
|
||||||
|
"build:server": "tsc -p server/tsconfig.json",
|
||||||
|
"start": "node dist/server/index.js",
|
||||||
|
"typecheck": "tsc -p server/tsconfig.json --noEmit && tsc -p frontend/tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
|
"express": "^4.22.3",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
|
"openid-client": "^6.8.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
|
"@types/express": "^4.17.25",
|
||||||
|
"@types/express-session": "^1.19.0",
|
||||||
|
"@types/node": "^26.6.3",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.1",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
|
"react": "^19.3.0",
|
||||||
|
"react-dom": "^19.3.0",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tsx": "^4.23.15",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x='3' y='9' width='14' height='28' rx='3' fill='#6a3de8'/><rect x='31' y='11' width='14' height='28' rx='3' fill='#6a3de8'/><rect x='7' y='31' width='6' height='2.5' rx='1.25' fill='#2B0F71'/><rect x='35' y='33' width='6' height='2.5' rx='1.25' fill='#2B0F71'/><path fill='#2B0F71' d='M19 21.5h6.5V16l8 8-8 8v-5.5H19z'/></svg>
|
||||||
|
After Width: | Height: | Size: 393 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x='6' y='6' width='15' height='15' rx='3' fill='#6a3de8'/><rect x='27' y='6' width='15' height='15' rx='3' fill='#2B0F71'/><rect x='6' y='27' width='15' height='15' rx='3' fill='#2B0F71'/><rect x='27' y='27' width='15' height='15' rx='3' fill='#6a3de8'/></svg>
|
||||||
|
After Width: | Height: | Size: 327 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x='4' y='10' width='40' height='28' rx='4' fill='#6a3de8'/><circle cx='16' cy='21' r='5' fill='#2B0F71'/><path fill='#2B0F71' d='M7.5 33a8.5 7 0 0 1 17 0zM28 18h11v3H28zm0 7h8v3h-8z'/></svg>
|
||||||
|
After Width: | Height: | Size: 257 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x='4' y='6' width='40' height='10' rx='2.5' fill='#2B0F71'/><path fill='#6a3de8' d='M7 19h34v19a4 4 0 0 1-4 4H11a4 4 0 0 1-4-4z'/><rect x='18' y='24' width='12' height='4' rx='2' fill='#2B0F71'/></svg>
|
||||||
|
After Width: | Height: | Size: 268 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x='3' y='6' width='42' height='28' rx='4' fill='#6a3de8'/><path fill='#2B0F71' d='M17 38h14v4H17z'/><path fill='#2B0F71' d='M24 11l9 3.4v6.2c0 5.6-3.8 9.6-9 11.4-5.2-1.8-9-5.8-9-11.4v-6.2z'/><path fill='#fff' d='m22.2 23.9-3-3 2-2 1 1 5-5 2 2z'/></svg>
|
||||||
|
After Width: | Height: | Size: 319 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill='#6a3de8' d='M22.6 6H38a4 4 0 0 1 4 4v15.4a4 4 0 0 1-1.2 2.8L26.2 42.8a4 4 0 0 1-5.6 0L5.2 27.4a4 4 0 0 1 0-5.6L19.8 7.2A4 4 0 0 1 22.6 6Z'/><circle cx='33' cy='15' r='3.6' fill='#2B0F71'/><path fill='#2B0F71' d='M15 24.5l8.5 8.5-2.8 2.8-8.5-8.5z'/><rect x='22' y='32' width='24' height='13' rx='3' fill='#f59e0b'/><text x='34' y='42' font-family='Arial,sans-serif' font-size='10' font-weight='700' text-anchor='middle' fill='#1f1300'>DEV</text></svg>
|
||||||
|
After Width: | Height: | Size: 523 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill='#6a3de8' d='M22.6 6H38a4 4 0 0 1 4 4v15.4a4 4 0 0 1-1.2 2.8L26.2 42.8a4 4 0 0 1-5.6 0L5.2 27.4a4 4 0 0 1 0-5.6L19.8 7.2A4 4 0 0 1 22.6 6Z'/><circle cx='33' cy='15' r='3.6' fill='#2B0F71'/><path fill='#2B0F71' d='M15 24.5l8.5 8.5-2.8 2.8-8.5-8.5z'/></svg>
|
||||||
|
After Width: | Height: | Size: 327 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx='24' cy='24' r='20' fill='#6a3de8'/><circle cx='24' cy='24' r='9' fill='#fff'/><path fill='#2B0F71' d='M17.2 6.2a19.5 19.5 0 0 1 13.6 0l-3.1 9.3a9.5 9.5 0 0 0-7.4 0zM41.8 17.2a19.5 19.5 0 0 1 0 13.6l-9.3-3.1a9.5 9.5 0 0 0 0-7.4zM30.8 41.8a19.5 19.5 0 0 1-13.6 0l3.1-9.3a9.5 9.5 0 0 0 7.4 0zM6.2 30.8a19.5 19.5 0 0 1 0-13.6l9.3 3.1a9.5 9.5 0 0 0 0 7.4z'/></svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x='6' y='6' width='24' height='34' rx='3' fill='#6a3de8'/><path fill='#2B0F71' d='M11 13h14v3H11zm0 7h10v3H11z'/><circle cx='33' cy='31' r='11' fill='#2B0F71'/><path fill='#fff' d='M31.5 25h3v6.2l3.6 3.6-2.1 2.1-4.5-4.5z'/></svg>
|
||||||
|
After Width: | Height: | Size: 296 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><rect x='9' y='4' width='22' height='40' rx='4' fill='#6a3de8'/><rect x='16' y='37' width='8' height='3' rx='1.5' fill='#2B0F71'/><path fill='#2B0F71' d='M36 17a9 9 0 1 1-8.2 12.7l3.6-1.6A5 5 0 1 0 36 21h-1v3l-5-5 5-5v3z'/></svg>
|
||||||
|
After Width: | Height: | Size: 290 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx='24' cy='25' r='18' fill='#6a3de8'/><rect x='21' y='3' width='6' height='5' rx='1.5' fill='#2B0F71'/><path fill='#2B0F71' d='M22 14h4v10.2l6.4 6.4-2.8 2.8L22 25.8z'/><rect x='22' y='32' width='24' height='13' rx='3' fill='#f59e0b'/><text x='34' y='42' font-family='Arial,sans-serif' font-size='10' font-weight='700' text-anchor='middle' fill='#1f1300'>DEV</text></svg>
|
||||||
|
After Width: | Height: | Size: 440 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx='24' cy='25' r='18' fill='#6a3de8'/><rect x='21' y='3' width='6' height='5' rx='1.5' fill='#2B0F71'/><path fill='#2B0F71' d='M22 14h4v10.2l6.4 6.4-2.8 2.8L22 25.8z'/></svg>
|
||||||
|
After Width: | Height: | Size: 244 B |
@@ -0,0 +1,259 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Richtet Authentik für das Handy-Insel Portal ein (idempotent, mehrfach ausführbar).
|
||||||
|
|
||||||
|
AK_TOKEN=<admin-api-token> python3 scripts/authentik-setup.py [--apply] [--env /etc/hi-portal/portal.env]
|
||||||
|
|
||||||
|
Ohne --apply wird nur angezeigt, was passieren würde (Probelauf).
|
||||||
|
|
||||||
|
Schritte:
|
||||||
|
1. Apps an Gruppen binden (wer sieht/öffnet welches Tool)
|
||||||
|
2. OIDC-Provider + App "Portal" anlegen (für alle HI-Mitarbeiter)
|
||||||
|
3. Service-Account "hi-portal" mit Leserecht auf "Apps pro Benutzer" + API-Token
|
||||||
|
4. Branding (Titel "Handy-Insel", Logo/Favicon vom Portal)
|
||||||
|
5. Client-ID/Secret/Token in die portal.env schreiben (falls --env angegeben)
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
AK_URL = os.environ.get("AK_URL", "https://login.handy-insel.de").rstrip("/")
|
||||||
|
PORTAL_URL = os.environ.get("PORTAL_URL", "https://portal.handy-insel.de").rstrip("/")
|
||||||
|
|
||||||
|
# Wer darf welche App öffnen (Slug -> Gruppen). Mitglieder einer der Gruppen haben Zugriff.
|
||||||
|
APP_ACCESS = {
|
||||||
|
"preisliste": ["HI-Mitarbeiter"],
|
||||||
|
"vvl": ["HI-Mitarbeiter"],
|
||||||
|
"zeiterfassung": ["HI-Mitarbeiter"],
|
||||||
|
"timer": ["HI-Mitarbeiter"],
|
||||||
|
"kundenkarte": ["HI-Mitarbeiter"],
|
||||||
|
"datenuebertragungen": ["HI-Mitarbeiter"],
|
||||||
|
"service": ["HI-Mitarbeiter"],
|
||||||
|
"portal": ["HI-Mitarbeiter"],
|
||||||
|
"paperless": ["HI-Leitung", "HI-Admin"],
|
||||||
|
"patch-mon": ["HI-Admin"],
|
||||||
|
"preisliste-dev": ["HI-Admin"],
|
||||||
|
"zeit-dev": ["HI-Admin"],
|
||||||
|
}
|
||||||
|
|
||||||
|
SERVICE_ACCOUNT = "hi-portal"
|
||||||
|
SERVICE_GROUP = "hi-portal-service"
|
||||||
|
SERVICE_ROLE = "Portal: Apps pro Benutzer lesen"
|
||||||
|
SERVICE_PERMISSIONS = ["authentik_core.view_user", "authentik_core.view_user_applications"]
|
||||||
|
|
||||||
|
APPLY = False
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, path, body=None):
|
||||||
|
if method != "GET" and not APPLY:
|
||||||
|
print(f" [Probelauf] {method} {path}")
|
||||||
|
return {}
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{AK_URL}/api/v3{path}",
|
||||||
|
method=method,
|
||||||
|
data=json.dumps(body).encode() if body is not None else None,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {os.environ['AK_TOKEN']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
sys.exit(f"FEHLER {method} {path}: HTTP {e.code} {e.read().decode()[:400]}")
|
||||||
|
|
||||||
|
|
||||||
|
def results(path):
|
||||||
|
sep = "&" if "?" in path else "?"
|
||||||
|
return call("GET", f"{path}{sep}page_size=200")["results"]
|
||||||
|
|
||||||
|
|
||||||
|
def step(title):
|
||||||
|
print(f"\n== {title}")
|
||||||
|
|
||||||
|
|
||||||
|
def bind_apps(groups, apps):
|
||||||
|
step("1. Apps an Gruppen binden")
|
||||||
|
for slug, group_names in APP_ACCESS.items():
|
||||||
|
app = apps.get(slug)
|
||||||
|
if not app:
|
||||||
|
print(f" - {slug}: App existiert (noch) nicht, übersprungen")
|
||||||
|
continue
|
||||||
|
existing = results(f"/policies/bindings/?target={app['pk']}")
|
||||||
|
bound = {b.get("group") for b in existing}
|
||||||
|
orders = {b["order"] for b in existing}
|
||||||
|
for name in group_names:
|
||||||
|
if groups[name] in bound:
|
||||||
|
print(f" = {slug} -> {name}")
|
||||||
|
continue
|
||||||
|
order = 10
|
||||||
|
while order in orders:
|
||||||
|
order += 1
|
||||||
|
orders.add(order)
|
||||||
|
call("POST", "/policies/bindings/", {
|
||||||
|
"target": app["pk"], "group": groups[name], "order": order,
|
||||||
|
"enabled": True, "negate": False, "timeout": 30, "failure_result": False,
|
||||||
|
})
|
||||||
|
print(f" + {slug} -> {name}")
|
||||||
|
if app.get("policy_engine_mode") != "any":
|
||||||
|
call("PATCH", f"/core/applications/{slug}/", {"policy_engine_mode": "any"})
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_portal_app(apps):
|
||||||
|
step("2. OIDC-Provider und App 'Portal'")
|
||||||
|
template = next(p for p in results("/providers/oauth2/") if p["name"] == "Preisliste")
|
||||||
|
provider = next((p for p in results("/providers/oauth2/") if p["name"] == "Portal"), None)
|
||||||
|
if provider:
|
||||||
|
print(" = Provider 'Portal' vorhanden")
|
||||||
|
else:
|
||||||
|
provider = call("POST", "/providers/oauth2/", {
|
||||||
|
"name": "Portal",
|
||||||
|
"authorization_flow": template["authorization_flow"],
|
||||||
|
"invalidation_flow": template["invalidation_flow"],
|
||||||
|
"client_type": "confidential",
|
||||||
|
"redirect_uris": [{"matching_mode": "strict", "url": f"{PORTAL_URL}/auth/callback"}],
|
||||||
|
"property_mappings": template["property_mappings"],
|
||||||
|
"signing_key": template.get("signing_key"),
|
||||||
|
"sub_mode": template.get("sub_mode", "hashed_user_id"),
|
||||||
|
"include_claims_in_id_token": True,
|
||||||
|
})
|
||||||
|
print(" + Provider 'Portal' angelegt")
|
||||||
|
if "portal" in apps:
|
||||||
|
print(" = App 'portal' vorhanden")
|
||||||
|
elif APPLY:
|
||||||
|
apps["portal"] = call("POST", "/core/applications/", {
|
||||||
|
"name": "Portal", "slug": "portal", "provider": provider["pk"],
|
||||||
|
"meta_launch_url": f"{PORTAL_URL}/", "policy_engine_mode": "any",
|
||||||
|
})
|
||||||
|
print(" + App 'portal' angelegt")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_service_account(groups):
|
||||||
|
step("3. Service-Account für die App-Liste")
|
||||||
|
user = next((u for u in results(f"/core/users/?username={SERVICE_ACCOUNT}") if u["username"] == SERVICE_ACCOUNT), None)
|
||||||
|
token_key = None
|
||||||
|
if user:
|
||||||
|
print(f" = Service-Account '{SERVICE_ACCOUNT}' vorhanden")
|
||||||
|
else:
|
||||||
|
res = call("POST", "/core/users/service_account/", {"name": SERVICE_ACCOUNT, "create_group": False, "expiring": False})
|
||||||
|
token_key = res.get("token")
|
||||||
|
user = {"pk": res.get("user_pk")}
|
||||||
|
print(f" + Service-Account '{SERVICE_ACCOUNT}' angelegt")
|
||||||
|
|
||||||
|
role = next((r for r in results("/rbac/roles/") if r["name"] == SERVICE_ROLE), None)
|
||||||
|
if not role:
|
||||||
|
role = call("POST", "/rbac/roles/", {"name": SERVICE_ROLE})
|
||||||
|
print(f" + Rolle '{SERVICE_ROLE}' angelegt")
|
||||||
|
if role.get("pk"):
|
||||||
|
call("POST", f"/rbac/permissions/assigned_by_roles/{role['pk']}/assign/", {"permissions": SERVICE_PERMISSIONS})
|
||||||
|
print(" + Rechte: " + ", ".join(SERVICE_PERMISSIONS))
|
||||||
|
|
||||||
|
if SERVICE_GROUP not in groups:
|
||||||
|
call("POST", "/core/groups/", {
|
||||||
|
"name": SERVICE_GROUP, "is_superuser": False,
|
||||||
|
"users": [user["pk"]] if user.get("pk") else [],
|
||||||
|
"roles": [role["pk"]] if role.get("pk") else [],
|
||||||
|
})
|
||||||
|
print(f" + Gruppe '{SERVICE_GROUP}' (Rolle + Service-Account)")
|
||||||
|
else:
|
||||||
|
print(f" = Gruppe '{SERVICE_GROUP}' vorhanden")
|
||||||
|
|
||||||
|
if not token_key and APPLY:
|
||||||
|
tokens = [t for t in results(f"/core/tokens/?user__username={SERVICE_ACCOUNT}") if t["intent"] == "api"]
|
||||||
|
if tokens:
|
||||||
|
token_key = call("GET", f"/core/tokens/{tokens[0]['identifier']}/view_key/")["key"]
|
||||||
|
return user, token_key
|
||||||
|
|
||||||
|
|
||||||
|
def branding():
|
||||||
|
step("4. Branding")
|
||||||
|
brand = next((b for b in results("/core/brands/") if b.get("default")), None) or results("/core/brands/")[0]
|
||||||
|
call("PATCH", f"/core/brands/{brand['brand_uuid']}/", {
|
||||||
|
"branding_title": "Handy-Insel",
|
||||||
|
"branding_logo": f"{PORTAL_URL}/icon-192.png",
|
||||||
|
"branding_favicon": f"{PORTAL_URL}/favicon.png",
|
||||||
|
})
|
||||||
|
print(" + Titel 'Handy-Insel', Logo und Favicon vom Portal")
|
||||||
|
|
||||||
|
|
||||||
|
def write_env(path, values):
|
||||||
|
step(f"5. {path} aktualisieren")
|
||||||
|
text = open(path).read() if os.path.exists(path) else ""
|
||||||
|
for key, value in values.items():
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
line = f"{key}={value}"
|
||||||
|
text = re.sub(rf"^{key}=.*$", line, text, flags=re.M) if re.search(rf"^{key}=", text, flags=re.M) else text + f"\n{line}\n"
|
||||||
|
if APPLY:
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
print(" + " + ", ".join(k for k, v in values.items() if v))
|
||||||
|
|
||||||
|
|
||||||
|
def verify(token, user_pk):
|
||||||
|
step("Prüfung: Kann der Service-Account die Apps eines Benutzers lesen?")
|
||||||
|
kerim = next((u for u in results("/core/users/?username=kerim")), None)
|
||||||
|
if not (token and kerim):
|
||||||
|
print(" (übersprungen)")
|
||||||
|
return
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{AK_URL}/api/v3/core/applications/?for_user={kerim['pk']}&page_size=200",
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as r:
|
||||||
|
slugs = [a["slug"] for a in json.load(r)["results"]]
|
||||||
|
print(f" OK – Apps für kerim: {', '.join(slugs)}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f" FEHLER HTTP {e.code}: {e.read().decode()[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global APPLY
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Änderungen wirklich durchführen")
|
||||||
|
parser.add_argument("--env", help="Pfad zur portal.env, in die Client-ID/Secret/Token geschrieben werden")
|
||||||
|
parser.add_argument("--skip-branding", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
APPLY = args.apply
|
||||||
|
if "AK_TOKEN" not in os.environ:
|
||||||
|
sys.exit("Bitte AK_TOKEN (API-Token eines Authentik-Admins) setzen.")
|
||||||
|
|
||||||
|
print(f"Authentik: {AK_URL} | Portal: {PORTAL_URL} | {'AUSFÜHREN' if APPLY else 'PROBELAUF'}")
|
||||||
|
groups = {g["name"]: g["pk"] for g in results("/core/groups/")}
|
||||||
|
for needed in ("HI-Mitarbeiter", "HI-Leitung", "HI-Admin"):
|
||||||
|
if needed not in groups:
|
||||||
|
sys.exit(f"Gruppe {needed} fehlt in Authentik.")
|
||||||
|
apps = {a["slug"]: a for a in results("/core/applications/?superuser_full_list=true")}
|
||||||
|
|
||||||
|
provider = ensure_portal_app(apps)
|
||||||
|
bind_apps(groups, apps)
|
||||||
|
user, token = ensure_service_account(groups)
|
||||||
|
if not args.skip_branding:
|
||||||
|
branding()
|
||||||
|
if args.env:
|
||||||
|
write_env(args.env, {
|
||||||
|
"OIDC_CLIENT_ID": provider.get("client_id"),
|
||||||
|
"OIDC_CLIENT_SECRET": provider.get("client_secret"),
|
||||||
|
"AUTHENTIK_TOKEN": token,
|
||||||
|
})
|
||||||
|
elif APPLY:
|
||||||
|
print("\nFür die portal.env:")
|
||||||
|
print(f" OIDC_CLIENT_ID={provider.get('client_id')}")
|
||||||
|
print(f" OIDC_CLIENT_SECRET={provider.get('client_secret')}")
|
||||||
|
print(f" AUTHENTIK_TOKEN={token}")
|
||||||
|
if APPLY:
|
||||||
|
verify(token, user.get("pk"))
|
||||||
|
print("\nFertig." if APPLY else "\nProbelauf beendet – mit --apply ausführen.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}"`);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* Handy-Insel Kopfleiste (<hi-topbar>) – zentrale Navigation für alle Tools.
|
||||||
|
*
|
||||||
|
* Einbindung in ein Tool (eine Zeile, am besten im <head>):
|
||||||
|
* <script src="https://portal.handy-insel.de/topbar.js" data-app="vvl" defer></script>
|
||||||
|
*
|
||||||
|
* Das Skript fügt die Leiste automatisch oben in <body> ein. Wer sie selbst
|
||||||
|
* platzieren will, setzt <hi-topbar app="vvl"></hi-topbar> ins Markup.
|
||||||
|
* Die Höhe steht als CSS-Variable --hi-topbar-height bereit, z.B. für
|
||||||
|
* Layouts mit height: calc(100vh - var(--hi-topbar-height, 0px)).
|
||||||
|
*
|
||||||
|
* Voraussetzung in der CSP des Tools: script-src, connect-src und img-src
|
||||||
|
* müssen https://portal.handy-insel.de erlauben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface NavApp {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
iconUrl: string;
|
||||||
|
newTab: boolean;
|
||||||
|
section: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Navigation {
|
||||||
|
user: { name: string; email: string; initials: string } | null;
|
||||||
|
apps: NavApp[];
|
||||||
|
links: { portal: string; account: string; logout: string; login: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEIGHT = 44;
|
||||||
|
const CACHE_KEY = 'hi-topbar-nav';
|
||||||
|
const CACHE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
const script = document.currentScript as HTMLScriptElement | null;
|
||||||
|
const PORTAL = (script?.dataset.portal || (script?.src ? new URL(script.src).origin : 'https://portal.handy-insel.de')).replace(/\/$/, '');
|
||||||
|
|
||||||
|
const css = `
|
||||||
|
:host { all: initial; display: block; position: relative; z-index: 2147483000; height: ${HEIGHT}px;
|
||||||
|
font-family: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif; font-size: 14px; color: #fff; }
|
||||||
|
:host([sticky]) { position: sticky; top: 0; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
.bar { display: flex; align-items: center; gap: 6px; height: ${HEIGHT}px; padding: 0 8px 0 4px; background: #2b0f71;
|
||||||
|
box-shadow: 0 1px 0 rgba(0,0,0,.12); }
|
||||||
|
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
.icon-btn { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 8px; }
|
||||||
|
.icon-btn:hover, .icon-btn[aria-expanded="true"] { background: rgba(255,255,255,.14); }
|
||||||
|
.icon-btn:focus-visible, a:focus-visible { outline: 2px solid #cbbdf7; outline-offset: 1px; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 8px; padding: 4px 8px 4px 2px; border-radius: 8px; white-space: nowrap; }
|
||||||
|
.brand:hover { background: rgba(255,255,255,.08); }
|
||||||
|
.brand img { width: 24px; height: 24px; border-radius: 50%; background: #fff; }
|
||||||
|
.brand b { font-weight: 700; }
|
||||||
|
.app-name { opacity: .85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
||||||
|
.sep { width: 1px; height: 18px; background: rgba(255,255,255,.3); margin: 0 4px; }
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.avatar { width: 30px; height: 30px; border-radius: 50%; background: #fff; color: #2b0f71; font-size: 12px; font-weight: 700;
|
||||||
|
display: grid; place-items: center; }
|
||||||
|
.panel { position: absolute; top: ${HEIGHT + 6}px; background: #fff; color: #1f2a52; border-radius: 12px;
|
||||||
|
border: 1px solid #e2e8f0; box-shadow: 0 12px 32px rgba(15,23,42,.18); overflow: hidden; }
|
||||||
|
.panel[hidden] { display: none; }
|
||||||
|
.apps { left: 6px; width: 344px; max-width: calc(100vw - 12px); max-height: calc(100vh - 60px); overflow-y: auto; padding: 12px; }
|
||||||
|
.apps h3 { margin: 4px 6px 8px; font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; margin-bottom: 8px; }
|
||||||
|
.tile { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 4px; border-radius: 10px;
|
||||||
|
text-align: center; font-size: 12.5px; font-weight: 500; line-height: 1.2; overflow-wrap: anywhere; hyphens: auto; }
|
||||||
|
.tile:hover { background: #f3f0fd; }
|
||||||
|
.tile[aria-current="page"] { background: #e6defb; }
|
||||||
|
.tile img { width: 36px; height: 36px; }
|
||||||
|
.footer { display: block; margin: 4px -12px -12px; padding: 12px 18px; border-top: 1px solid #f1f5f9; font-weight: 600; color: #5a2fd6; }
|
||||||
|
.footer:hover { background: #f3f0fd; }
|
||||||
|
.hint { margin: 6px; font-size: 13px; line-height: 1.45; color: #475569; }
|
||||||
|
.hint a { color: #5a2fd6; font-weight: 600; }
|
||||||
|
.user { right: 6px; width: 260px; }
|
||||||
|
.user .who { padding: 12px 16px; border-bottom: 1px solid #f1f5f9; }
|
||||||
|
.user .who div:first-child { font-weight: 600; }
|
||||||
|
.user .who div:last-child { font-size: 13px; color: #64748b; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.user a { display: block; padding: 10px 16px; font-size: 14px; }
|
||||||
|
.user a:hover { background: #f3f0fd; }
|
||||||
|
@media (max-width: 480px) { .brand span { display: none; } }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const WAFFLE =
|
||||||
|
'<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 4h4v4H4zm6 0h4v4h-4zm6 0h4v4h-4zM4 10h4v4H4zm6 0h4v4h-4zm6 0h4v4h-4zM4 16h4v4H4zm6 0h4v4h-4zm6 0h4v4h-4z"/></svg>';
|
||||||
|
|
||||||
|
function esc(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCache(): Navigation | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(CACHE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const { at, nav } = JSON.parse(raw) as { at: number; nav: Navigation };
|
||||||
|
return Date.now() - at < CACHE_MS ? nav : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(nav: Navigation | null): void {
|
||||||
|
try {
|
||||||
|
if (nav?.user) sessionStorage.setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), nav }));
|
||||||
|
else sessionStorage.removeItem(CACHE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* Speicher gesperrt – dann eben ohne Cache */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HiTopbar extends HTMLElement {
|
||||||
|
private root = this.attachShadow({ mode: 'open' });
|
||||||
|
private nav: Navigation | null = null;
|
||||||
|
private open: 'apps' | 'user' | null = null;
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
document.documentElement.style.setProperty('--hi-topbar-height', `${HEIGHT}px`);
|
||||||
|
this.nav = readCache();
|
||||||
|
this.render();
|
||||||
|
void this.load();
|
||||||
|
document.addEventListener('mousedown', this.onOutside);
|
||||||
|
document.addEventListener('keydown', this.onKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
document.removeEventListener('mousedown', this.onOutside);
|
||||||
|
document.removeEventListener('keydown', this.onKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get appSlug(): string {
|
||||||
|
return this.getAttribute('app') || script?.dataset.app || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${PORTAL}/api/navigation`, { credentials: 'include' });
|
||||||
|
const nav = (await res.json()) as Navigation;
|
||||||
|
this.nav = nav;
|
||||||
|
writeCache(nav);
|
||||||
|
} catch {
|
||||||
|
// Portal nicht erreichbar: Leiste bleibt mit Link zum Portal nutzbar.
|
||||||
|
if (!this.nav) this.nav = null;
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onOutside = (e: MouseEvent) => {
|
||||||
|
if (this.open && !e.composedPath().includes(this)) this.toggle(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && this.open) this.toggle(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
private toggle(which: 'apps' | 'user' | null): void {
|
||||||
|
this.open = this.open === which ? null : which;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
const nav = this.nav;
|
||||||
|
const links = nav?.links ?? {
|
||||||
|
portal: `${PORTAL}/`,
|
||||||
|
login: `${PORTAL}/auth/login`,
|
||||||
|
logout: `${PORTAL}/auth/logout`,
|
||||||
|
account: '',
|
||||||
|
};
|
||||||
|
const current = nav?.apps.find((a) => a.slug === this.appSlug);
|
||||||
|
const appName = current?.name || this.getAttribute('app-name') || '';
|
||||||
|
const loginUrl = `${links.login}?returnTo=${encodeURIComponent(location.href)}`;
|
||||||
|
|
||||||
|
const tile = (a: NavApp) =>
|
||||||
|
`<a class="tile" href="${esc(a.url)}"${a.newTab ? ' target="_blank" rel="noopener"' : ''}${
|
||||||
|
a.slug === this.appSlug ? ' aria-current="page"' : ''
|
||||||
|
}><img src="${esc(a.iconUrl)}" alt="">${esc(a.name)}</a>`;
|
||||||
|
const sections = new Map<string, NavApp[]>();
|
||||||
|
for (const a of nav?.apps ?? []) sections.set(a.section, [...(sections.get(a.section) ?? []), a]);
|
||||||
|
const tiles = [...sections]
|
||||||
|
.map(([title, apps]) => `<h3>${esc(title)}</h3><div class="grid">${apps.map(tile).join('')}</div>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const appsPanel = nav?.user
|
||||||
|
? `${tiles}<a class="footer" href="${esc(links.portal)}">Zum Portal →</a>`
|
||||||
|
: `<p class="hint">Deine Anwendungen erscheinen hier, sobald du im Portal angemeldet bist.<br><a href="${esc(loginUrl)}">Jetzt anmelden</a></p>`;
|
||||||
|
|
||||||
|
const user = nav?.user;
|
||||||
|
this.root.innerHTML = `<style>${css}</style>
|
||||||
|
<nav class="bar" aria-label="Handy-Insel Navigation">
|
||||||
|
<button class="icon-btn" data-toggle="apps" aria-haspopup="true" aria-expanded="${this.open === 'apps'}" title="Anwendungen">${WAFFLE}</button>
|
||||||
|
<a class="brand" href="${esc(links.portal)}"><img src="${PORTAL}/icon-192.png" alt=""><span>Handy-<b>Insel</b></span></a>
|
||||||
|
${appName ? `<span class="sep"></span><span class="app-name">${esc(appName)}</span>` : ''}
|
||||||
|
<span class="spacer"></span>
|
||||||
|
${
|
||||||
|
user
|
||||||
|
? `<button class="icon-btn" data-toggle="user" aria-haspopup="true" aria-expanded="${this.open === 'user'}" title="${esc(user.name)}"><span class="avatar">${esc(user.initials)}</span></button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
<div class="panel apps" lang="de" ${this.open === 'apps' ? '' : 'hidden'}>${appsPanel}</div>
|
||||||
|
${
|
||||||
|
user
|
||||||
|
? `<div class="panel user" ${this.open === 'user' ? '' : 'hidden'}>
|
||||||
|
<div class="who"><div>${esc(user.name)}</div><div>${esc(user.email)}</div></div>
|
||||||
|
<a href="${esc(links.portal)}">Portal</a>
|
||||||
|
${links.account ? `<a href="${esc(links.account)}">Konto & Passwort</a>` : ''}
|
||||||
|
<a href="${esc(links.logout)}">Abmelden</a>
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
this.root.querySelectorAll<HTMLButtonElement>('[data-toggle]').forEach((btn) =>
|
||||||
|
btn.addEventListener('click', () => this.toggle(btn.dataset.toggle as 'apps' | 'user')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('hi-topbar')) customElements.define('hi-topbar', HiTopbar);
|
||||||
|
|
||||||
|
function autoInsert(): void {
|
||||||
|
if (script?.dataset.auto === 'false' || document.querySelector('hi-topbar')) return;
|
||||||
|
const bar = document.createElement('hi-topbar');
|
||||||
|
if (script?.dataset.sticky !== undefined) bar.setAttribute('sticky', '');
|
||||||
|
document.body.prepend(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', autoInsert);
|
||||||
|
else autoInsert();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'frontend',
|
||||||
|
publicDir: '../public',
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
build: { outDir: '../dist/public', emptyOutDir: true },
|
||||||
|
server: {
|
||||||
|
port: 5180,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3010',
|
||||||
|
'/auth': 'http://localhost:3010',
|
||||||
|
'/topbar.js': 'http://localhost:3010',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||