From 6606420b4a310191a595b366a256ee08747bdf5e Mon Sep 17 00:00:00 2001 From: Kerim Atilgan Date: Sun, 27 Sep 2026 12:18:32 +0200 Subject: [PATCH] Handy-Insel Portal: Startseite, Kopfleiste und Authentik-Anbindung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Portal mit Kacheln aus Authentik (pro Benutzer gefiltert) und "Aktuelles" - Web-Component 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) --- .env.example | 25 + .gitignore | 5 + README.md | 100 + deploy/deploy.sh | 31 + deploy/hi-portal.service | 22 + deploy/setup-ct.sh | 29 + frontend/index.html | 15 + frontend/src/App.tsx | 328 +++ frontend/src/api.ts | 65 + frontend/src/env.d.ts | 1 + frontend/src/icons.tsx | 19 + frontend/src/main.tsx | 10 + frontend/src/styles.css | 25 + frontend/tsconfig.json | 14 + package-lock.json | 3646 ++++++++++++++++++++++++++ package.json | 44 + public/favicon.png | Bin 0 -> 3432 bytes public/icon-192.png | Bin 0 -> 29925 bytes public/icons/datenuebertragungen.svg | 1 + public/icons/default.svg | 1 + public/icons/kundenkarte.svg | 1 + public/icons/paperless.svg | 1 + public/icons/patch-mon.svg | 1 + public/icons/preisliste-dev.svg | 1 + public/icons/preisliste.svg | 1 + public/icons/service.svg | 1 + public/icons/timer.svg | 1 + public/icons/vvl.svg | 1 + public/icons/zeit-dev.svg | 1 + public/icons/zeiterfassung.svg | 1 + scripts/authentik-setup.py | 259 ++ server/src/authentik.ts | 98 + server/src/config.ts | 82 + server/src/db.ts | 38 + server/src/index.ts | 87 + server/src/oidc.ts | 76 + server/src/routes/api.ts | 149 ++ server/src/routes/auth.ts | 66 + server/src/sessionStore.ts | 54 + server/src/types.ts | 19 + server/tsconfig.json | 14 + topbar/src/topbar.ts | 226 ++ vite.config.ts | 18 + 43 files changed, 5577 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100755 deploy/deploy.sh create mode 100644 deploy/hi-portal.service create mode 100755 deploy/setup-ct.sh create mode 100644 frontend/index.html create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/env.d.ts create mode 100644 frontend/src/icons.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/tsconfig.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.png create mode 100644 public/icon-192.png create mode 100644 public/icons/datenuebertragungen.svg create mode 100644 public/icons/default.svg create mode 100644 public/icons/kundenkarte.svg create mode 100644 public/icons/paperless.svg create mode 100644 public/icons/patch-mon.svg create mode 100644 public/icons/preisliste-dev.svg create mode 100644 public/icons/preisliste.svg create mode 100644 public/icons/service.svg create mode 100644 public/icons/timer.svg create mode 100644 public/icons/vvl.svg create mode 100644 public/icons/zeit-dev.svg create mode 100644 public/icons/zeiterfassung.svg create mode 100644 scripts/authentik-setup.py create mode 100644 server/src/authentik.ts create mode 100644 server/src/config.ts create mode 100644 server/src/db.ts create mode 100644 server/src/index.ts create mode 100644 server/src/oidc.ts create mode 100644 server/src/routes/api.ts create mode 100644 server/src/routes/auth.ts create mode 100644 server/src/sessionStore.ts create mode 100644 server/src/types.ts create mode 100644 server/tsconfig.json create mode 100644 topbar/src/topbar.ts create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8acb284 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddc0ba2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +data/ +.env +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ef5c04 --- /dev/null +++ b/README.md @@ -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 ``**: 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/.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/.svg` und Reihenfolge in `APP_ORDER`. +4. Kopfleiste im Tool einbinden (siehe unten). + +## Kopfleiste in ein Tool einbauen + +```html + +``` + +- `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 `` platzieren. +- Die Leiste ist 44 px hoch und setzt `--hi-topbar-height` auf ``. 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= python3 /opt/hi-portal/scripts/authentik-setup.py +AK_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= 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). diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..4d4b079 --- /dev/null +++ b/deploy/deploy.sh @@ -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 diff --git a/deploy/hi-portal.service b/deploy/hi-portal.service new file mode 100644 index 0000000..918fc55 --- /dev/null +++ b/deploy/hi-portal.service @@ -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 diff --git a/deploy/setup-ct.sh b/deploy/setup-ct.sh new file mode 100755 index 0000000..a51ef8f --- /dev/null +++ b/deploy/setup-ct.sh @@ -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 </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)" diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..736504b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Handy-Insel Portal + + +
+ + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1c81a2a --- /dev/null +++ b/frontend/src/App.tsx @@ -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(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
Lade Portal …
; + + return ( +
+
+
+
+

+ {greeting()}, +
+ {me.user.firstName}. +

+ +
+ +
+
+ ); +} + +/* ------------------------------------------------------------------ Kopfzeile */ + +function Header({ user, links }: { user: User; links: Links }) { + const [open, setOpen] = useState(false); + const ref = useRef(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 ( +
+
+ + + + Handy-Insel + + +
+ + {open && ( +
+
+
{user.name}
+
{user.email}
+
+ + Konto & Passwort + + + Abmelden + +
+ )} +
+
+
+ ); +} + +/* ------------------------------------------------------------------ Anwendungen */ + +function AppSections({ apps, error }: { apps: PortalApp[] | null; error: string }) { + const sections = useMemo(() => { + const map = new Map(); + for (const app of apps ?? []) map.set(app.section, [...(map.get(app.section) ?? []), app]); + return [...map.entries()]; + }, [apps]); + + if (apps === null) { + return ( + +
Lade Anwendungen …
+
+ ); + } + if (!apps.length) { + return ( + +

+ {error || 'Für dein Konto sind noch keine Anwendungen freigeschaltet. Bitte wende dich an einen Admin.'} +

+
+ ); + } + return ( + <> + {sections.map(([title, list]) => ( + +
    + {list.map((app) => ( +
  • + +
  • + ))} +
+
+ ))} + + ); +} + +function SectionTitle({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ + {title} +

+ {children} +
+ ); +} + +function AppTile({ app }: { app: PortalApp }) { + return ( + + + + + {app.name} + + ); +} + +/* ------------------------------------------------------------------ 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(null); + const [editing, setEditing] = useState(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 ( + + ); +} + +function IconButton({ label, icon, onClick }: { label: string; icon: 'pencil' | 'trash'; onClick: () => void }) { + return ( + + ); +} + +function NewsForm({ + initial, + onSave, + onCancel, +}: { + initial?: News; + onSave: (input: NewsInput) => Promise; + 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 ( +
+ setTitle(e.target.value)} maxLength={200} required autoFocus /> +