Handy-Insel Portal: Startseite, Kopfleiste und Authentik-Anbindung
- Portal mit Kacheln aus Authentik (pro Benutzer gefiltert) und "Aktuelles" - Web-Component <hi-topbar> als zentraler App-Umschalter für alle Tools - OIDC-Login über Authentik, zentrales Abmelden - Setup-Skript für Authentik (Gruppen-Bindungen, Portal-App, Service-Account) - Deployment auf den Portal-CT per systemd Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
6606420b4a
43 files changed
+5577
No files matched your search
@@ -0,0 +1,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' }),
|
||||
};
|
||||
Vendored
+1
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user