feat: marketplace modules and isolated container management
This commit is contained in:
@@ -1,30 +1,40 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../../features/auth/auth-context';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { ProfilePage } from '../../features/profile/profile-page';
|
||||
import { Icon, type IconName } from '../ui/icon';
|
||||
|
||||
/** Ein Navigationspunkt der Sidebar. */
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
icon: IconName;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: '⌂' },
|
||||
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
|
||||
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
|
||||
{ to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true },
|
||||
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
|
||||
{ to: '/admin/audit', label: 'Audit-Log', icon: '📋', adminOnly: true },
|
||||
{ to: '/admin/settings', label: 'Einstellungen', icon: '🔧', adminOnly: true },
|
||||
{ to: '/', label: 'Dashboard', icon: 'dashboard' },
|
||||
{ to: '/admin/users', label: 'Benutzer', icon: 'users', adminOnly: true },
|
||||
{ to: '/admin/modules', label: 'Module', icon: 'modules', adminOnly: true },
|
||||
{ to: '/admin/system', label: 'Systemstatus', icon: 'system', adminOnly: true },
|
||||
{ to: '/admin/audit', label: 'Audit-Log', icon: 'audit', adminOnly: true },
|
||||
];
|
||||
|
||||
function initialDarkMode(): boolean {
|
||||
return document.documentElement.dataset.theme === 'dark';
|
||||
}
|
||||
|
||||
/** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */
|
||||
export function AppLayout(): ReactNode {
|
||||
const { user, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(initialDarkMode);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = darkMode ? 'dark' : 'light';
|
||||
localStorage.setItem('mpm-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
|
||||
const visibleItems = NAV_ITEMS.filter(
|
||||
(item) => !item.adminOnly || user?.role === 'ADMIN',
|
||||
@@ -70,22 +80,28 @@ export function AppLayout(): ReactNode {
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true">{item.icon}</span>
|
||||
<Icon name={item.icon} className="h-[18px] w-[18px]" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{user?.displayName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-500">@{user?.username}</p>
|
||||
</div>
|
||||
{user?.role === 'ADMIN' && <Badge variant="info">Admin</Badge>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProfileOpen(true)}
|
||||
className="group flex w-full min-w-0 items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`Profileinstellungen für ${user?.displayName ?? 'Benutzer'} öffnen`}
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600">
|
||||
<Icon name="user" className="h-[18px] w-[18px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-900 group-hover:text-brand-700">
|
||||
{user?.displayName}
|
||||
</span>
|
||||
<Icon name="arrow" className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void logout()}
|
||||
className="mt-3 w-full rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
@@ -105,18 +121,31 @@ export function AppLayout(): ReactNode {
|
||||
aria-label="Menü öffnen"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
<span aria-hidden="true" className="text-xl">☰</span>
|
||||
<Icon name="menu" className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="hidden text-sm text-slate-500 lg:block">
|
||||
Management-Plattform
|
||||
</span>
|
||||
<span className="hidden text-sm text-slate-500 lg:block">Management-Plattform</span>
|
||||
<span className="text-sm text-slate-500 lg:hidden">MPM</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={darkMode}
|
||||
aria-label="Darkmode"
|
||||
onClick={() => setDarkMode((current) => !current)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" />
|
||||
<span>{darkMode ? 'Hell' : 'Dunkel'}</span>
|
||||
<span className={`relative h-5 w-9 rounded-full transition-colors ${darkMode ? 'bg-brand-600' : 'bg-slate-300'}`}>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${darkMode ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{profileOpen && <ProfilePage onClose={() => setProfileOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function RequireAuth({ children }: { children: ReactNode }): ReactNode {
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
@@ -32,4 +32,4 @@ export function RequireAdmin({ children }: { children: ReactNode }): ReactNode {
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface CardProps {
|
||||
export function Card({ children, className = '' }: CardProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-slate-200 bg-white shadow-sm ${className}`}
|
||||
className={`rounded-2xl border border-slate-200 bg-white shadow-sm ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -25,7 +25,7 @@ export interface CardHeaderProps {
|
||||
/** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */
|
||||
export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-slate-200 px-6 py-4">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
|
||||
@@ -42,5 +42,5 @@ export interface CardBodyProps {
|
||||
|
||||
/** Karten-Inhalt. */
|
||||
export function CardBody({ children, className = '' }: CardBodyProps): ReactNode {
|
||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
||||
}
|
||||
return <div className={`px-5 py-5 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
36
apps/platform-frontend/src/components/ui/icon.tsx
Normal file
36
apps/platform-frontend/src/components/ui/icon.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export type IconName = 'dashboard' | 'users' | 'modules' | 'system' | 'audit' | 'moon' | 'sun' | 'user' | 'arrow' | 'close' | 'menu' | 'plus' | 'check';
|
||||
|
||||
const PATHS: Record<IconName, ReactNode> = {
|
||||
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></>,
|
||||
users: <><path d="M16 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="10" cy="7" r="4" /><path d="M20 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" /></>,
|
||||
modules: <><rect x="3" y="3" width="8" height="8" rx="2" /><rect x="13" y="3" width="8" height="5" rx="2" /><rect x="13" y="10" width="8" height="11" rx="2" /><rect x="3" y="13" width="8" height="8" rx="2" /></>,
|
||||
system: <><path d="M12 8v4l2.5 2.5" /><circle cx="12" cy="12" r="9" /><path d="M12 3v2m9 7h-2M5 12H3m9 9v-2" /></>,
|
||||
audit: <><path d="M8 4h10a2 2 0 0 1 2 2v14H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2Z" /><path d="M8 2v4m0 4h8m-8 4h8m-8 4h5" /></>,
|
||||
moon: <path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z" />,
|
||||
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42" /></>,
|
||||
user: <><circle cx="12" cy="8" r="4" /><path d="M5 21a7 7 0 0 1 14 0" /></>,
|
||||
arrow: <><path d="M5 12h14m-6-6 6 6-6 6" /></>,
|
||||
close: <><path d="m18 6-12 12M6 6l12 12" /></>,
|
||||
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
check: <path d="m5 12 4 4L19 6" />,
|
||||
};
|
||||
|
||||
export function Icon({ name, className = 'h-5 w-5' }: { name: IconName; className?: string }): ReactNode {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface ModalProps {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
panelClassName?: string;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
/** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */
|
||||
@@ -17,6 +19,8 @@ export function Modal({
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
panelClassName = 'max-w-md',
|
||||
hideHeader = false,
|
||||
}: ModalProps): ReactNode {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -45,16 +49,18 @@ export function Modal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="w-full max-w-md rounded-xl bg-white shadow-xl"
|
||||
className={`w-full rounded-xl bg-white shadow-xl ${panelClassName}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-slate-200 px-6 py-4">
|
||||
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<div className="border-b border-slate-200 px-6 py-4">
|
||||
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
{footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +76,10 @@ export function AuditPage(): ReactNode {
|
||||
const totalPages = auditQuery.data ? Math.ceil(auditQuery.data.total / pageSize) : 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Audit-Log</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
<h1 className="text-3xl font-bold text-slate-900">Audit-Log</h1>
|
||||
<p className="mt-2 text-base text-slate-500">
|
||||
Sicherheitsrelevante Ereignisse der Plattform (neueste zuerst).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { type ReactNode, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Icon } from '../../components/ui/icon';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import { useToast } from '../../components/ui/toast';
|
||||
import { ApiError } from '../../lib/api-client';
|
||||
import {
|
||||
checkModuleHealth,
|
||||
beginMarketplaceConnection,
|
||||
disconnectMarketplaceProvider,
|
||||
fetchMarketplaceRepositories,
|
||||
fetchMarketplaceProviders,
|
||||
fetchModules,
|
||||
installModule,
|
||||
installMarketplaceRepository,
|
||||
removeModule,
|
||||
restartModule,
|
||||
setModuleEnabled,
|
||||
startModule,
|
||||
stopModule,
|
||||
type MarketplaceProvider,
|
||||
} from '../../lib/modules-api';
|
||||
import type { Module, ModuleStatus } from '../../lib/schemas';
|
||||
|
||||
@@ -43,6 +50,7 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
|
||||
mutationFn: () => removeModule(module.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
|
||||
showToast('success', `Modul "${module.name}" wurde entfernt`);
|
||||
onClose();
|
||||
},
|
||||
@@ -55,7 +63,7 @@ function RemoveModuleModal({ module, onClose }: { module: Module; onClose: () =>
|
||||
<Modal
|
||||
open
|
||||
title="Modul entfernen"
|
||||
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? Dateien und Registrierung werden gelöscht.`}
|
||||
description={`Möchten Sie "${module.name}" (Version ${module.version}) wirklich entfernen? App- und Datenbankcontainer sowie Dateien und Registrierung werden gelöscht. Datenvolumes bleiben für eine mögliche Neuinstallation erhalten.`}
|
||||
onClose={onClose}
|
||||
>
|
||||
{formError && (
|
||||
@@ -86,6 +94,8 @@ export function ModulesPage(): ReactNode {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<Module | null>(null);
|
||||
const [connectTarget, setConnectTarget] = useState<MarketplaceProvider['provider'] | null>(null);
|
||||
const [connectionTab, setConnectionTab] = useState<'providers' | 'manual'>('providers');
|
||||
const [healthResults, setHealthResults] = useState<Record<string, { healthy: boolean; detail: string }>>({});
|
||||
|
||||
const modulesQuery = useQuery({
|
||||
@@ -93,6 +103,45 @@ export function ModulesPage(): ReactNode {
|
||||
queryFn: fetchModules,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const marketplaceQuery = useQuery({
|
||||
queryKey: ['marketplace-providers'],
|
||||
queryFn: fetchMarketplaceProviders,
|
||||
});
|
||||
const connectedProviders = marketplaceQuery.data?.filter((item) => item.connected) ?? [];
|
||||
const repositoriesQuery = useQuery({
|
||||
queryKey: ['marketplace-repositories', connectedProviders.map((item) => item.provider)],
|
||||
queryFn: async () => Promise.all(connectedProviders.map(async (item) => ({
|
||||
...item,
|
||||
repositories: await fetchMarketplaceRepositories(item.provider),
|
||||
}))),
|
||||
enabled: connectedProviders.length > 0,
|
||||
});
|
||||
|
||||
const connectMutation = useMutation({
|
||||
mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => beginMarketplaceConnection(provider),
|
||||
onSuccess: (authorizationUrl) => window.location.assign(authorizationUrl),
|
||||
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht gestartet werden'),
|
||||
});
|
||||
|
||||
const disconnectMutation = useMutation({
|
||||
mutationFn: (provider: 'github' | 'gitea' | 'forgejo') => disconnectMarketplaceProvider(provider),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplace-providers'] });
|
||||
showToast('success', 'Forge-Verbindung getrennt');
|
||||
},
|
||||
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Verbindung konnte nicht getrennt werden'),
|
||||
});
|
||||
|
||||
const marketplaceInstallMutation = useMutation({
|
||||
mutationFn: (input: { provider: MarketplaceProvider['provider']; owner: string; repository: string }) =>
|
||||
installMarketplaceRepository(input.provider, input.owner, input.repository),
|
||||
onSuccess: (module) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marketplace-repositories'] });
|
||||
showToast('success', `Modul "${module.name}" wurde installiert. Zum Starten bitte „Start“ wählen.`);
|
||||
},
|
||||
onError: (error) => showToast('error', error instanceof ApiError ? error.message : 'Marketplace-Installation fehlgeschlagen'),
|
||||
});
|
||||
|
||||
const invalidate = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['modules'] });
|
||||
@@ -163,190 +212,318 @@ export function ModulesPage(): ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Modulverwaltung</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
<h1 className="text-3xl font-bold text-slate-900">Modulverwaltung</h1>
|
||||
<p className="mt-1 text-base text-slate-500">
|
||||
Module installieren, starten, stoppen und entfernen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Installation */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
|
||||
aria-label="Modul-Paket (ZIP) auswählen"
|
||||
/>
|
||||
<Button
|
||||
disabled={!selectedFile}
|
||||
loading={installMutation.isPending}
|
||||
onClick={handleInstall}
|
||||
>
|
||||
Installieren
|
||||
</Button>
|
||||
<p className="text-xs text-slate-500">
|
||||
ZIP-Paket mit module.json (Manifest). Maximal 10 MB.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{new URLSearchParams(window.location.search).get('marketplace') === 'connected' && (
|
||||
<p role="status" className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
Forge-Konto wurde erfolgreich verbunden.
|
||||
</p>
|
||||
)}
|
||||
{new URLSearchParams(window.location.search).get('marketplace') === 'denied' && (
|
||||
<p role="status" className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Autorisierung wurde abgebrochen.
|
||||
</p>
|
||||
)}
|
||||
{new URLSearchParams(window.location.search).get('marketplace') === 'error' && (
|
||||
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||
{new URLSearchParams(window.location.search).get('reason') === 'session'
|
||||
? 'MPM konnte deine Sitzung beim OAuth-Rücksprung nicht zuordnen. Verwende vor und nach der Anmeldung dieselbe Adresse (http://127.0.0.1:8081) und starte die Verbindung erneut.'
|
||||
: new URLSearchParams(window.location.search).get('reason') === 'callback'
|
||||
? 'Die OAuth-Rückgabe war unvollständig. Bitte starte die Verbindung erneut.'
|
||||
: 'Verbindung fehlgeschlagen. Prüfe OAuth-Konfiguration und Callback-URL und starte die Verbindung erneut.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Compact provider connectors */}
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900">Verbindungen</h2>
|
||||
<div role="tablist" aria-label="Verbindungen und manuelle Installation" className="mb-4 flex gap-2 border-b border-slate-200">
|
||||
<button type="button" role="tab" aria-selected={connectionTab === 'providers'} onClick={() => setConnectionTab('providers')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'providers' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}>
|
||||
Verbindungen
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={connectionTab === 'manual'} onClick={() => setConnectionTab('manual')} className={`border-b-2 px-3 py-2 text-sm font-medium transition-colors ${connectionTab === 'manual' ? 'border-brand-600 text-brand-700' : 'border-transparent text-slate-500 hover:text-slate-800'}`}>
|
||||
Manuelle Installation
|
||||
</button>
|
||||
</div>
|
||||
{connectionTab === 'providers' ? (
|
||||
<div className="grid grid-cols-1">
|
||||
{(['github', 'gitea', 'forgejo'] as const).map((provider) => {
|
||||
const connection = marketplaceQuery.data?.find((item) => item.provider === provider);
|
||||
const label = connection?.label ?? (provider === 'github' ? 'GitHub' : provider === 'gitea' ? 'Gitea' : 'Forgejo');
|
||||
const statusText = marketplaceQuery.isLoading
|
||||
? 'Status wird geladen…'
|
||||
: connection?.connected
|
||||
? `Verbunden als ${connection.accountLogin}`
|
||||
: connection?.configured
|
||||
? 'Marketplace verbinden'
|
||||
: 'Nicht eingerichtet';
|
||||
return (
|
||||
<div key={provider} className="flex min-w-0 items-center gap-3 border-b border-slate-200 py-3 last:border-b-0">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-slate-200 bg-slate-50 text-slate-900">
|
||||
<ProviderMark provider={provider} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">{label}</p>
|
||||
<p className="truncate text-xs text-slate-500">{statusText}</p>
|
||||
</div>
|
||||
{connection?.connected ? (
|
||||
<Button
|
||||
className="h-9 w-9 shrink-0 p-0"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-label={`${label}-Verbindung trennen`}
|
||||
title={`${label}-Verbindung trennen`}
|
||||
loading={disconnectMutation.isPending && disconnectMutation.variables === provider}
|
||||
onClick={() => disconnectMutation.mutate(provider)}
|
||||
>
|
||||
<Icon name="check" className="h-4 w-4 text-emerald-600" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="h-9 w-9 shrink-0 p-0"
|
||||
size="sm"
|
||||
aria-label={`Mit ${label} verbinden`}
|
||||
title={connection?.configured ? `Mit ${label} verbinden` : `OAuth-Konfiguration für ${label} fehlt`}
|
||||
disabled={!connection?.configured || marketplaceQuery.isLoading}
|
||||
loading={connectMutation.isPending && connectMutation.variables === provider}
|
||||
onClick={() => setConnectTarget(provider)}
|
||||
>
|
||||
<Icon name="plus" className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div role="tabpanel" className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
className="text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-brand-50 file:px-3 file:py-2 file:text-sm file:font-medium file:text-brand-700 hover:file:bg-brand-100"
|
||||
aria-label="Modul-Paket (ZIP) auswählen"
|
||||
/>
|
||||
<Button disabled={!selectedFile} loading={installMutation.isPending} onClick={handleInstall}>
|
||||
Installieren
|
||||
</Button>
|
||||
<p className="text-xs text-slate-500">ZIP-Paket mit module.json (Manifest). Maximal 10 MB.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{/* Modul-Liste */}
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<table className="w-full min-w-[760px] text-sm">
|
||||
<thead>
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-900">Installierte Module</h2>
|
||||
<div className="max-h-[42rem] overflow-auto rounded-xl border border-slate-200">
|
||||
<table className="w-full min-w-[900px] table-fixed text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th className="px-4 py-3 font-semibold">Modul</th>
|
||||
<th className="px-4 py-3 font-semibold">Status</th>
|
||||
<th className="px-4 py-3 font-semibold">URL</th>
|
||||
<th className="px-4 py-3 font-semibold">Aktionen</th>
|
||||
<th className="w-[34%] px-4 py-3 font-semibold">Modul</th>
|
||||
<th className="w-[13%] px-4 py-3 font-semibold">Status</th>
|
||||
<th className="w-[15%] px-4 py-3 font-semibold">URL</th>
|
||||
<th className="w-[38%] px-4 py-3 font-semibold">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modulesQuery.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-slate-500">
|
||||
Module werden geladen…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{modulesQuery.isError && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-red-600">
|
||||
Module konnten nicht geladen werden.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{modulesQuery.isLoading && <tr><td colSpan={4} className="px-5 py-10 text-center text-slate-500">Module werden geladen…</td></tr>}
|
||||
{modulesQuery.isError && <tr><td colSpan={4} className="px-5 py-10 text-center text-red-600">Module konnten nicht geladen werden.</td></tr>}
|
||||
{modulesQuery.data?.map((module) => (
|
||||
<tr key={module.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-slate-900">{module.name}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{module.moduleId} · Version {module.version}
|
||||
{module.author && ` · ${module.author}`}
|
||||
</div>
|
||||
{module.description && (
|
||||
<div className="mt-0.5 text-xs text-slate-500">{module.description}</div>
|
||||
)}
|
||||
<div className="font-semibold text-slate-900">{module.name}</div>
|
||||
<div className="text-xs text-slate-500">{module.moduleId} · Version {module.version}{module.author && ` · ${module.author}`}</div>
|
||||
{module.description && <div className="mt-0.5 text-xs text-slate-500">{module.description}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
|
||||
${
|
||||
statusVariant(module.status) === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20'
|
||||
: statusVariant(module.status) === 'warning'
|
||||
? 'bg-amber-50 text-amber-700 ring-amber-600/20'
|
||||
: statusVariant(module.status) === 'danger'
|
||||
? 'bg-red-50 text-red-700 ring-red-600/20'
|
||||
: statusVariant(module.status) === 'neutral'
|
||||
? 'bg-slate-100 text-slate-600 ring-slate-500/20'
|
||||
: 'bg-brand-50 text-brand-700 ring-brand-600/20'
|
||||
}`}
|
||||
>
|
||||
{module.status}
|
||||
</span>
|
||||
{healthResults[module.id] && (
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
|
||||
/{module.slug}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{module.status !== 'RUNNING' && module.enabled && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
lifecycleMutation.variables?.action === 'start'
|
||||
}
|
||||
onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{(module.status === 'RUNNING' || module.status === 'STARTING') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
lifecycleMutation.variables?.action === 'stop'
|
||||
}
|
||||
onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
lifecycleMutation.variables?.action === 'restart'
|
||||
}
|
||||
onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => healthMutation.mutate(module)}
|
||||
>
|
||||
Health
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={
|
||||
lifecycleMutation.isPending &&
|
||||
lifecycleMutation.variables?.module.id === module.id &&
|
||||
(lifecycleMutation.variables?.action === 'enable' ||
|
||||
lifecycleMutation.variables?.action === 'disable')
|
||||
}
|
||||
onClick={() =>
|
||||
lifecycleMutation.mutate({
|
||||
module,
|
||||
action: module.enabled ? 'disable' : 'enable',
|
||||
})
|
||||
}
|
||||
>
|
||||
{module.enabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<td className="px-4 py-3"><span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${statusVariant(module.status) === 'success' ? 'bg-emerald-50 text-emerald-700 ring-emerald-600/20' : statusVariant(module.status) === 'warning' ? 'bg-amber-50 text-amber-700 ring-amber-600/20' : statusVariant(module.status) === 'danger' ? 'bg-red-50 text-red-700 ring-red-600/20' : statusVariant(module.status) === 'neutral' ? 'bg-slate-100 text-slate-600 ring-slate-500/20' : 'bg-brand-50 text-brand-700 ring-brand-600/20'}`}>{module.status}</span>
|
||||
{healthResults[module.id] && <div className="mt-1 text-xs text-slate-500">Health: {healthResults[module.id].healthy ? '✓' : '✕'} {healthResults[module.id].detail}</div>}
|
||||
</td>
|
||||
<td className="px-4 py-3"><code className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">/{module.slug}</code></td>
|
||||
<td className="px-4 py-3"><div className="flex flex-wrap gap-1">
|
||||
{module.status !== 'RUNNING' && module.enabled && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'start'} onClick={() => lifecycleMutation.mutate({ module, action: 'start' })}>Start</Button>}
|
||||
{(module.status === 'RUNNING' || module.status === 'STARTING') && <Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'stop'} onClick={() => lifecycleMutation.mutate({ module, action: 'stop' })}>Stop</Button>}
|
||||
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && lifecycleMutation.variables?.action === 'restart'} onClick={() => lifecycleMutation.mutate({ module, action: 'restart' })}>Restart</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => healthMutation.mutate(module)}>Health</Button>
|
||||
<Button size="sm" variant="ghost" loading={lifecycleMutation.isPending && lifecycleMutation.variables?.module.id === module.id && (lifecycleMutation.variables?.action === 'enable' || lifecycleMutation.variables?.action === 'disable')} onClick={() => lifecycleMutation.mutate({ module, action: module.enabled ? 'disable' : 'enable' })}>{module.enabled ? 'Disable' : 'Enable'}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setRemoveTarget(module)}>Remove</Button>
|
||||
</div></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{modulesQuery.data?.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-slate-500">
|
||||
Noch keine Module installiert.
|
||||
{modulesQuery.data?.length === 0 && <p className="px-4 py-8 text-center text-slate-500">Noch keine Module installiert.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Marketplace</h2>
|
||||
<p className="mt-1 text-base text-slate-500">
|
||||
Öffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM prüft das Modulmanifest vor der Installation.
|
||||
</p>
|
||||
</div>
|
||||
{connectedProviders.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
|
||||
Verbinde zuerst GitHub, Gitea oder Forgejo.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{repositoriesQuery.isLoading && <p className="py-4 text-sm text-slate-500">Repositories werden geladen...</p>}
|
||||
{repositoriesQuery.isError && <p role="alert" className="py-4 text-sm text-red-600">Repositories konnten nicht geladen werden.</p>}
|
||||
<div className="max-h-[42rem] space-y-4 overflow-y-auto pr-2">
|
||||
{repositoriesQuery.data?.map((provider) => (
|
||||
<div key={provider.provider}>
|
||||
<h3 className="mb-3 text-base font-semibold text-slate-800">{provider.label}</h3>
|
||||
{provider.repositories.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Keine öffentlichen Repositories gefunden.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{provider.repositories.map((repo) => (
|
||||
<article key={repo.owner + '/' + repo.repository} className="flex items-center justify-between gap-3 rounded-xl border border-slate-200 p-3 transition-colors hover:border-slate-400 hover:bg-slate-50">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-slate-50 text-slate-800"><ProviderMark provider={provider.provider} /></span>
|
||||
<a className="truncate text-sm font-medium text-brand-700 hover:underline" href={repo.htmlUrl} target="_blank" rel="noreferrer">
|
||||
{repo.owner}/{repo.repository}
|
||||
</a>
|
||||
</div>
|
||||
{repo.description && <p className="mt-1 line-clamp-2 text-xs text-slate-500">{repo.description}</p>}
|
||||
<p className="mt-1 text-xs text-slate-500">Branch: {repo.defaultBranch}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={repo.installed ? 'secondary' : 'primary'}
|
||||
disabled={repo.installed}
|
||||
className={repo.installed ? '!border-emerald-600 !bg-emerald-600 !text-white hover:!bg-emerald-600 disabled:!text-white' : ''}
|
||||
loading={marketplaceInstallMutation.isPending && marketplaceInstallMutation.variables?.repository === repo.repository}
|
||||
onClick={() => marketplaceInstallMutation.mutate({ provider: provider.provider, owner: repo.owner, repository: repo.repository })}
|
||||
>
|
||||
{repo.installed ? 'Installiert' : 'Installieren'}
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{removeTarget && (
|
||||
<RemoveModuleModal module={removeTarget} onClose={() => setRemoveTarget(null)} />
|
||||
)}
|
||||
{connectTarget && (
|
||||
<ConnectProviderModal
|
||||
provider={connectTarget}
|
||||
label={marketplaceQuery.data?.find((item) => item.provider === connectTarget)?.label ??
|
||||
(connectTarget === 'github' ? 'GitHub' : connectTarget === 'gitea' ? 'Gitea' : 'Forgejo')}
|
||||
loading={connectMutation.isPending}
|
||||
onClose={() => setConnectTarget(null)}
|
||||
onContinue={() => connectMutation.mutate(connectTarget)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function ProviderMark({ provider }: { provider: 'github' | 'gitea' | 'forgejo' }): ReactNode {
|
||||
if (provider === 'github') {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6" aria-hidden="true" fill="currentColor">
|
||||
<path d="M12 .9a11.1 11.1 0 0 0-3.51 21.63c.56.1.76-.24.76-.54v-2.08c-3.1.67-3.76-1.32-3.76-1.32-.51-1.3-1.24-1.65-1.24-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.73 1.16 1.73 1.16 1 .1.95 2.17 3.64 1.54.1-.74.39-1.25.7-1.54-2.48-.28-5.09-1.24-5.09-5.52 0-1.22.44-2.22 1.16-3-.12-.29-.5-1.43.11-2.98 0 0 .95-.3 3.05 1.15a10.6 10.6 0 0 1 5.55 0c2.1-1.45 3.04-1.15 3.04-1.15.61 1.55.23 2.69.12 2.98.72.78 1.15 1.78 1.15 3 0 4.3-2.61 5.23-5.1 5.5.4.35.75 1.02.75 2.06v3.04c0 .3.2.65.77.54A11.1 11.1 0 0 0 12 .9Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (provider === 'gitea') {
|
||||
return <span className="text-xl font-bold tracking-tight text-orange-500" aria-hidden="true">G</span>;
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6 text-emerald-500" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M4 18h16l-3-4h-4l-2-4H8l2 4H7l-3 4Zm5-10 3-4 3 4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectProviderModal({
|
||||
provider,
|
||||
label,
|
||||
loading,
|
||||
onClose,
|
||||
onContinue,
|
||||
}: {
|
||||
provider: MarketplaceProvider['provider'];
|
||||
label: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onContinue: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={`${label} verbinden`}
|
||||
onClose={onClose}
|
||||
hideHeader
|
||||
panelClassName="max-w-xl overflow-hidden rounded-[28px]"
|
||||
>
|
||||
<div className="relative max-h-[90vh] overflow-y-auto px-1 pb-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dialog schließen"
|
||||
onClick={onClose}
|
||||
className="absolute right-1 top-0 rounded-full p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 pb-5 pt-2">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-900 text-white">
|
||||
<Icon name="modules" className="h-9 w-9" />
|
||||
</span>
|
||||
<span className="flex gap-1.5" aria-hidden="true">
|
||||
<span className="h-2 w-2 rounded-full bg-slate-300" />
|
||||
<span className="h-2 w-2 rounded-full bg-slate-300" />
|
||||
<span className="h-2 w-2 rounded-full bg-slate-300" />
|
||||
</span>
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-900">
|
||||
<ProviderMark provider={provider} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-2xl font-semibold text-slate-900">{label} verbinden</h2>
|
||||
<p className="mt-1 text-base text-slate-500">MPM möchte dein {label}-Konto mit dem Marketplace verbinden.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-slate-200 px-5 py-4 text-sm">
|
||||
<section>
|
||||
<h3 className="font-medium text-slate-900">Berechtigungen werden respektiert</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
MPM fordert nur den Zugriff auf deine Kontoinformationen an, um die Verbindung deinem Konto zuzuordnen. Schreibzugriff wird nicht angefordert.
|
||||
</p>
|
||||
</section>
|
||||
<section className="border-t border-slate-200 pt-4">
|
||||
<h3 className="font-medium text-slate-900">Du behältst die Kontrolle</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
Du kannst die Verbindung jederzeit in MPM trennen oder die App-Berechtigung in den Einstellungen von {label} widerrufen.
|
||||
</p>
|
||||
</section>
|
||||
<section className="border-t border-slate-200 pt-4">
|
||||
<h3 className="font-medium text-slate-900">Mit dieser App geteilte Daten</h3>
|
||||
<p className="mt-1 text-slate-600">
|
||||
MPM erhält deine Forge-Konto-ID, deinen Benutzernamen und ein Zugriffstoken. Das Token wird verschlüsselt gespeichert und nur serverseitig verwendet.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Button className="mt-6 h-11 w-full rounded-full" loading={loading} onClick={onContinue}>
|
||||
Weiter zu {label} <span aria-hidden="true">↗</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { type FormEvent, type ReactNode, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiRequest, ApiError } from '../../lib/api-client';
|
||||
import { z } from 'zod';
|
||||
import { Card, CardBody, CardHeader } from '../../components/ui/card';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { useToast } from '../../components/ui/toast';
|
||||
import { ErrorState, Spinner } from '../../components/ui/states';
|
||||
|
||||
/** Einstellung (API-Vertrag /api/v1/settings). */
|
||||
const settingSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
updatedAt: z.string(),
|
||||
updatedBy: z.string().nullable(),
|
||||
});
|
||||
|
||||
const settingsResponseSchema = z.object({
|
||||
settings: z.array(settingSchema),
|
||||
});
|
||||
|
||||
/** Anzeige-Namen für Einstellungs-Schlüssel. */
|
||||
const SETTING_LABELS: Record<string, string> = {
|
||||
'platform.name': 'Plattform-Name',
|
||||
'platform.description': 'Beschreibung',
|
||||
'platform.maintenance_mode': 'Wartungsmodus (true/false)',
|
||||
};
|
||||
|
||||
/** Systemeinstellungen-Seite (nur Admin). */
|
||||
export function SettingsPage(): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [editValues, setEditValues] = useState<Record<string, string>>({});
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => settingsResponseSchema.parse(await apiRequest('/api/v1/settings')),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (input: { key: string; value: string }) =>
|
||||
apiRequest<{ setting: unknown }>(`/api/v1/settings/${input.key}`, {
|
||||
method: 'PATCH',
|
||||
body: { value: input.value },
|
||||
}),
|
||||
onSuccess: (_result, variables) => {
|
||||
showToast('success', `Einstellung "${variables.key}" gespeichert`);
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast('error', error instanceof ApiError ? error.message : 'Speichern fehlgeschlagen');
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>, key: string): void {
|
||||
event.preventDefault();
|
||||
const value = editValues[key];
|
||||
if (value !== undefined && value.trim().length > 0) {
|
||||
updateMutation.mutate({ key, value: value.trim() });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Einstellungen</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Zentrale Konfiguration der Plattform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Plattform-Einstellungen" />
|
||||
<CardBody>
|
||||
{settingsQuery.isLoading && <Spinner label="Einstellungen werden geladen…" />}
|
||||
{settingsQuery.isError && (
|
||||
<ErrorState message="Einstellungen konnten nicht geladen werden." />
|
||||
)}
|
||||
{settingsQuery.data && settingsQuery.data.settings.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-slate-500">
|
||||
Noch keine Einstellungen vorhanden. Änderungen legen sie automatisch an.
|
||||
</p>
|
||||
)}
|
||||
{settingsQuery.data && settingsQuery.data.settings.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{settingsQuery.data.settings.map((setting) => (
|
||||
<form
|
||||
key={setting.key}
|
||||
onSubmit={(event) => handleSubmit(event, setting.key)}
|
||||
className="flex items-end gap-3"
|
||||
noValidate
|
||||
>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label={SETTING_LABELS[setting.key] ?? setting.key}
|
||||
value={editValues[setting.key] ?? setting.value}
|
||||
onChange={(event) =>
|
||||
setEditValues((current) => ({
|
||||
...current,
|
||||
[setting.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Zuletzt geändert von {setting.updatedBy ?? '–'} am{' '}
|
||||
{new Date(setting.updatedAt).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
loading={
|
||||
updateMutation.isPending && updateMutation.variables?.key === setting.key
|
||||
}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -45,10 +45,10 @@ export function SystemStatusPage(): ReactNode {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Systemstatus</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
<h1 className="text-3xl font-bold text-slate-900">Systemstatus</h1>
|
||||
<p className="mt-2 text-base text-slate-500">
|
||||
Zustand aller Plattform-Komponenten und Module (aktualisiert alle 30 Sekunden).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import { useToast } from '../../components/ui/toast';
|
||||
import { ApiError } from '../../lib/api-client';
|
||||
@@ -14,7 +13,7 @@ import type { Module, User } from '../../lib/schemas';
|
||||
|
||||
/**
|
||||
* Dialog: Modul-Berechtigungen eines Benutzers verwalten.
|
||||
* Zeigt alle installierten Module mit GRANTED/DENIED-Schaltern.
|
||||
* Zeigt alle installierten Module mit Freigabe-Checkboxen.
|
||||
*/
|
||||
export function UserPermissionsModal({
|
||||
user,
|
||||
@@ -84,7 +83,11 @@ export function UserPermissionsModal({
|
||||
<Modal
|
||||
open
|
||||
title="Modul-Berechtigungen"
|
||||
description={`Zugriff von ${user.displayName} (@${user.username}) auf Module verwalten.`}
|
||||
description={
|
||||
user.role === 'ADMIN'
|
||||
? 'Administratoren haben automatisch Zugriff auf alle aktivierten Module.'
|
||||
: `Zugriff von ${user.displayName} (@${user.username}) auf Module verwalten.`
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
{modulesQuery.isLoading || permissionsQuery.isLoading ? (
|
||||
@@ -98,36 +101,47 @@ export function UserPermissionsModal({
|
||||
Noch keine Module installiert.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{modulesQuery.data?.map((module) => {
|
||||
const isGranted = grantedModuleIds.has(module.id);
|
||||
return (
|
||||
<li
|
||||
key={module.id}
|
||||
className="flex items-center justify-between rounded-lg border border-slate-200 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{module.name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
/{module.slug} · Version {module.version}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isGranted ? 'danger' : 'primary'}
|
||||
loading={
|
||||
toggleMutation.isPending &&
|
||||
toggleMutation.variables?.module.id === module.id
|
||||
}
|
||||
onClick={() => handleToggle(module)}
|
||||
>
|
||||
{isGranted ? 'Entziehen' : 'Freigeben'}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<th scope="col" className="px-4 py-3 font-semibold">Modul</th>
|
||||
<th scope="col" className="w-32 px-4 py-3 text-center font-semibold">Freigeben</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modulesQuery.data?.map((module) => {
|
||||
const isAdmin = user.role === 'ADMIN';
|
||||
const isGranted = isAdmin || grantedModuleIds.has(module.id);
|
||||
const isSaving =
|
||||
toggleMutation.isPending && toggleMutation.variables?.module.id === module.id;
|
||||
|
||||
return (
|
||||
<tr key={module.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900">{module.name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
/{module.slug} · Version {module.version}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isGranted}
|
||||
disabled={isAdmin || isSaving}
|
||||
aria-label={`${module.name} für ${user.displayName} freigeben`}
|
||||
title={isAdmin ? 'Administratoren haben automatisch Zugriff' : undefined}
|
||||
className="h-4 w-4 cursor-pointer accent-brand-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onChange={() => handleToggle(module)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FormEvent, type ReactNode, useState } from 'react';
|
||||
import { type FormEvent, type ReactNode, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '../auth/auth-context';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -49,10 +49,10 @@ function fieldErrorsFromApi(details: Record<string, string | string[]> | undefin
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Datumsformat für Tabellenanzeigen. */
|
||||
/** Datumsformat für Tabellenanzeigen. */
|
||||
function formatDate(isoDate: string | null): string {
|
||||
if (!isoDate) {
|
||||
return '–';
|
||||
return '–';
|
||||
}
|
||||
return new Date(isoDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
@@ -118,7 +118,7 @@ function CreateUserModal({
|
||||
<Modal
|
||||
open={open}
|
||||
title="Benutzer anlegen"
|
||||
description="Neuen Benutzer für die Plattform registrieren"
|
||||
description="Neuen Benutzer für die Plattform registrieren"
|
||||
onClose={onClose}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
@@ -244,7 +244,7 @@ function EditUserModal({ user, onClose }: { user: User; onClose: () => void }):
|
||||
);
|
||||
}
|
||||
|
||||
/** Formular: Passwort zurücksetzen. */
|
||||
/** Formular: Passwort zurücksetzen. */
|
||||
function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
@@ -253,7 +253,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (input: { newPassword: string }) => resetUserPassword(user.id, input),
|
||||
onSuccess: () => {
|
||||
showToast('success', `Passwort für "${user.username}" wurde zurückgesetzt`);
|
||||
showToast('success', `Passwort für "${user.username}" wurde zurückgesetzt`);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -261,7 +261,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
||||
setFormError(error.message);
|
||||
setFieldErrors(fieldErrorsFromApi(error.details));
|
||||
} else {
|
||||
setFormError('Passwort konnte nicht zurückgesetzt werden');
|
||||
setFormError('Passwort konnte nicht zurückgesetzt werden');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -285,8 +285,8 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="Passwort zurücksetzen"
|
||||
description={`Neues Passwort für @${user.username} festlegen. Der Benutzer wird von allen Sitzungen abgemeldet.`}
|
||||
title="Passwort zurücksetzen"
|
||||
description={`Neues Passwort für @${user.username} festlegen. Der Benutzer wird von allen Sitzungen abgemeldet.`}
|
||||
onClose={onClose}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
@@ -308,7 +308,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" variant="danger" loading={resetMutation.isPending}>
|
||||
Zurücksetzen
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -316,7 +316,7 @@ function ResetPasswordModal({ user, onClose }: { user: User; onClose: () => void
|
||||
);
|
||||
}
|
||||
|
||||
/** Bestätigungsdialog: Benutzer löschen. */
|
||||
/** Bestätigungsdialog: Benutzer löschen. */
|
||||
function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void }): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -326,19 +326,19 @@ function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void })
|
||||
mutationFn: () => deleteUser(user.id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
showToast('success', `Benutzer "${user.username}" wurde gelöscht`);
|
||||
showToast('success', `Benutzer "${user.username}" wurde gelöscht`);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
setFormError(error instanceof ApiError ? error.message : 'Löschen fehlgeschlagen');
|
||||
setFormError(error instanceof ApiError ? error.message : 'Löschen fehlgeschlagen');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="Benutzer löschen"
|
||||
description={`Möchten Sie "${user.displayName}" (@${user.username}) wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`}
|
||||
title="Benutzer löschen"
|
||||
description={`Möchten Sie "${user.displayName}" (@${user.username}) wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`}
|
||||
onClose={onClose}
|
||||
>
|
||||
{formError && (
|
||||
@@ -355,14 +355,14 @@ function DeleteUserModal({ user, onClose }: { user: User; onClose: () => void })
|
||||
loading={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
Endgültig löschen
|
||||
Endgültig löschen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Benutzerverwaltung (nur Admin): Liste, Anlegen, Bearbeiten, Passwort, Löschen. */
|
||||
/** Benutzerverwaltung (nur Admin): Liste, Anlegen, Bearbeiten, Passwort, Löschen. */
|
||||
export function UsersPage(): ReactNode {
|
||||
const { user: currentUser } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -390,11 +390,11 @@ export function UsersPage(): ReactNode {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Benutzerverwaltung</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
<h1 className="text-3xl font-bold text-slate-900">Benutzerverwaltung</h1>
|
||||
<p className="mt-2 text-base text-slate-500">
|
||||
Benutzer anlegen, bearbeiten und verwalten.
|
||||
</p>
|
||||
</div>
|
||||
@@ -416,7 +416,7 @@ export function UsersPage(): ReactNode {
|
||||
{usersQuery.isLoading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
|
||||
Benutzer werden geladen…
|
||||
Benutzer werden geladen…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -431,9 +431,7 @@ export function UsersPage(): ReactNode {
|
||||
<tr key={user.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-slate-900">{user.displayName}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
@{user.username} · {user.email}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">@{user.username}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
@@ -444,7 +442,7 @@ export function UsersPage(): ReactNode {
|
||||
: 'bg-slate-100 text-slate-600 ring-slate-500/20'
|
||||
}`}
|
||||
>
|
||||
{user.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
|
||||
{user.role === 'ADMIN' ? 'Admin' : 'Benutzer'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -485,7 +483,7 @@ export function UsersPage(): ReactNode {
|
||||
{user.isActive ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setDeleteTarget(user)}>
|
||||
Löschen
|
||||
Löschen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { apiRequest, setUnauthorizedHandler } from '../../lib/api-client';
|
||||
import { ApiError, apiRequest, setUnauthorizedHandler } from '../../lib/api-client';
|
||||
import { authUserSchema, type AuthUser } from '../../lib/schemas';
|
||||
|
||||
/** Zustand des Auth-Contexts. */
|
||||
@@ -48,9 +48,26 @@ export function AuthProvider({ children }: { children: ReactNode }): ReactNode {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let sessionCheck: Promise<void> | null = null;
|
||||
setUnauthorizedHandler(() => {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
if (sessionCheck) return;
|
||||
|
||||
sessionCheck = apiRequest<{ user: unknown }>('/api/v1/auth/me')
|
||||
.then((response) => {
|
||||
const currentUser = authUserSchema.parse(response.user);
|
||||
setUser(currentUser);
|
||||
setStatus('authenticated');
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
// Network errors and server errors do not prove the session expired.
|
||||
})
|
||||
.finally(() => {
|
||||
sessionCheck = null;
|
||||
});
|
||||
});
|
||||
return () => setUnauthorizedHandler(() => undefined);
|
||||
}, []);
|
||||
@@ -89,4 +106,4 @@ export function useAuth(): AuthContextValue {
|
||||
throw new Error('useAuth muss innerhalb von AuthProvider verwendet werden');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { healthSchema, type Health } from '../../lib/schemas';
|
||||
import { Card, CardBody, CardHeader } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { EmptyState, ErrorState, Spinner } from '../../components/ui/states';
|
||||
import { Icon } from '../../components/ui/icon';
|
||||
|
||||
/** Dashboard: Begrüßung, eigene Anwendungen (nach Berechtigungen) und Systemstatus. */
|
||||
export function DashboardPage(): ReactNode {
|
||||
@@ -28,12 +29,12 @@ export function DashboardPage(): ReactNode {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="mx-auto max-w-screen-2xl space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
Willkommen, {user?.displayName} 👋
|
||||
<h1 className="text-3xl font-bold text-slate-900">
|
||||
Willkommen, {user?.displayName}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
<p className="mt-2 text-base text-slate-500">
|
||||
Ihre zentrale Anlaufstelle für alle freigegebenen Anwendungen.
|
||||
</p>
|
||||
</div>
|
||||
@@ -56,7 +57,7 @@ export function DashboardPage(): ReactNode {
|
||||
<EmptyState
|
||||
title="Noch keine Module freigegeben"
|
||||
description="Sobald der Administrator Ihnen Module zugewiesen hat, erscheinen diese hier als Kacheln."
|
||||
icon={<span className="text-3xl" aria-hidden="true">🧩</span>}
|
||||
icon={<Icon name="modules" className="h-8 w-8 text-slate-500" />}
|
||||
/>
|
||||
)}
|
||||
{modulesQuery.data && modulesQuery.data.length > 0 && (
|
||||
@@ -65,10 +66,10 @@ export function DashboardPage(): ReactNode {
|
||||
<a
|
||||
key={module.id}
|
||||
href={`/${module.slug}`}
|
||||
className="group rounded-xl border border-slate-200 p-5 transition-colors hover:border-brand-300 hover:bg-brand-50/50"
|
||||
className="mpm-module-tile group rounded-xl border border-slate-200 p-5 transition-all duration-150 hover:-translate-y-0.5 hover:border-slate-400 hover:bg-slate-50 hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-2xl" aria-hidden="true">🧩</span>
|
||||
<Icon name="modules" className="h-6 w-6 text-slate-500" />
|
||||
<Badge variant={module.status === 'RUNNING' ? 'success' : 'neutral'}>
|
||||
{module.status === 'RUNNING' ? 'Verfügbar' : module.status}
|
||||
</Badge>
|
||||
@@ -77,11 +78,11 @@ export function DashboardPage(): ReactNode {
|
||||
{module.name}
|
||||
</h3>
|
||||
{module.description && (
|
||||
<p className="mt-1 text-sm text-slate-500">{module.description}</p>
|
||||
<p className="mt-2 text-base text-slate-500">{module.description}</p>
|
||||
)}
|
||||
<span className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-brand-600">
|
||||
Öffnen
|
||||
<span aria-hidden="true" className="transition-transform group-hover:translate-x-0.5">→</span>
|
||||
<Icon name="arrow" className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
@@ -150,4 +151,4 @@ function StatusRow({
|
||||
<Badge variant={variant}>{status}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,36 +3,26 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardBody, CardHeader } from '../../components/ui/card';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Modal } from '../../components/ui/modal';
|
||||
import { useToast } from '../../components/ui/toast';
|
||||
import { ApiError } from '../../lib/api-client';
|
||||
import { changePassword, fetchProfile } from '../../lib/users-api';
|
||||
import { changePasswordSchema } from '../../lib/schemas';
|
||||
|
||||
/** Datumsformat für Profilanzeigen. */
|
||||
function formatDate(isoDate: string | null): string {
|
||||
if (!isoDate) {
|
||||
return '–';
|
||||
}
|
||||
if (!isoDate) return '–';
|
||||
return new Date(isoDate).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/** Profil des angemeldeten Benutzers mit Passwortänderung. */
|
||||
export function ProfilePage(): ReactNode {
|
||||
/** Profilinformationen und Passwortoptionen im Dialog am Benutzernamen. */
|
||||
export function ProfilePage({ onClose }: { onClose: () => void }): ReactNode {
|
||||
const { showToast } = useToast();
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: fetchProfile,
|
||||
});
|
||||
|
||||
const profileQuery = useQuery({ queryKey: ['profile'], queryFn: fetchProfile });
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: changePassword,
|
||||
onSuccess: () => {
|
||||
@@ -43,9 +33,8 @@ export function ProfilePage(): ReactNode {
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError) {
|
||||
setFormError(error.message);
|
||||
const details = error.details ?? {};
|
||||
const errors: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(details)) {
|
||||
for (const [key, value] of Object.entries(error.details ?? {})) {
|
||||
errors[key] = Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
@@ -59,7 +48,6 @@ export function ProfilePage(): ReactNode {
|
||||
event.preventDefault();
|
||||
setFieldErrors({});
|
||||
setFormError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const parsed = changePasswordSchema.safeParse({
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
@@ -70,9 +58,7 @@ export function ProfilePage(): ReactNode {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = String(issue.path[0] ?? 'form');
|
||||
if (!errors[key]) {
|
||||
errors[key] = issue.message;
|
||||
}
|
||||
if (!errors[key]) errors[key] = issue.message;
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return;
|
||||
@@ -80,105 +66,58 @@ export function ProfilePage(): ReactNode {
|
||||
changePasswordMutation.mutate(parsed.data);
|
||||
}
|
||||
|
||||
const profile = profileQuery.data;
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Mein Profil</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Ihre persönlichen Daten und Passwort-Einstellungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Persönliche Daten" />
|
||||
<CardBody>
|
||||
{profileQuery.isLoading && <p className="text-sm text-slate-500">Wird geladen…</p>}
|
||||
{profileQuery.isError && (
|
||||
<p className="text-sm text-red-600">Profil konnte nicht geladen werden.</p>
|
||||
)}
|
||||
{profileQuery.data && (
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Anzeigename</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
||||
{profileQuery.data.displayName}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Benutzername</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
||||
@{profileQuery.data.username}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">E-Mail</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
||||
{profileQuery.data.email}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Rolle</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
||||
{profileQuery.data.role === 'ADMIN' ? 'Administrator' : 'Benutzer'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Letzter Login</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
||||
{formatDate(profileQuery.data.lastLoginAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">Mitglied seit</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-slate-900">
|
||||
{formatDate(profileQuery.data.createdAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Passwort ändern"
|
||||
description="Nach der Änderung werden alle anderen Sitzungen abgemeldet."
|
||||
/>
|
||||
<CardBody>
|
||||
<form onSubmit={handleSubmit} className="max-w-sm space-y-4" noValidate>
|
||||
<Input
|
||||
label="Aktuelles Passwort"
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
error={fieldErrors.currentPassword}
|
||||
/>
|
||||
<Input
|
||||
label="Neues Passwort"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
hint="Mindestens 10 Zeichen"
|
||||
error={fieldErrors.newPassword}
|
||||
/>
|
||||
<Input
|
||||
label="Neues Passwort bestätigen"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
error={fieldErrors.confirmPassword}
|
||||
/>
|
||||
{formError && (
|
||||
<p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</p>
|
||||
<Modal
|
||||
open
|
||||
title="Profileinstellungen"
|
||||
description="Kontodetails und Passwort verwalten."
|
||||
onClose={onClose}
|
||||
panelClassName="max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader title="Persönliche Daten" />
|
||||
<CardBody>
|
||||
{profileQuery.isLoading && <p className="text-sm text-slate-500">Wird geladen…</p>}
|
||||
{profileQuery.isError && <p className="text-sm text-red-600">Profil konnte nicht geladen werden.</p>}
|
||||
{profile && (
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<ProfileField label="Anzeigename" value={profile.displayName} />
|
||||
<ProfileField label="Benutzername" value={`@${profile.username}`} />
|
||||
<ProfileField label="E-Mail" value={profile.email} />
|
||||
<ProfileField label="Rolle" value={profile.role === 'ADMIN' ? 'Administrator' : 'Benutzer'} />
|
||||
<ProfileField label="Letzter Login" value={formatDate(profile.lastLoginAt)} />
|
||||
<ProfileField label="Mitglied seit" value={formatDate(profile.createdAt)} />
|
||||
</dl>
|
||||
)}
|
||||
<Button type="submit" loading={changePasswordMutation.isPending}>
|
||||
Passwort ändern
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Passwort ändern" description="Andere aktive Sitzungen werden danach abgemeldet." />
|
||||
<CardBody>
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
<Input label="Aktuelles Passwort" name="currentPassword" type="password" autoComplete="current-password" error={fieldErrors.currentPassword} />
|
||||
<Input label="Neues Passwort" name="newPassword" type="password" autoComplete="new-password" hint="Mindestens 10 Zeichen" error={fieldErrors.newPassword} />
|
||||
<Input label="Neues Passwort bestätigen" name="confirmPassword" type="password" autoComplete="new-password" error={fieldErrors.confirmPassword} />
|
||||
{formError && <p role="alert" className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700">{formError}</p>}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={changePasswordMutation.isPending}>Passwort ändern</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileField({ label, value }: { label: string; value: string }): ReactNode {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">{label}</dt>
|
||||
<dd className="mt-1 text-sm font-medium text-slate-900">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,54 @@
|
||||
outline: 2px solid var(--color-brand-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Graustufen-Dunkelmodus: Oberflächen bleiben anthrazit statt tiefschwarz,
|
||||
Markenakzente werden neutral statt blau. */
|
||||
html[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--color-slate-50: #272a2d;
|
||||
--color-slate-100: #3c4044;
|
||||
--color-slate-200: #474b50;
|
||||
--color-slate-300: #565b61;
|
||||
--color-slate-400: #92979d;
|
||||
--color-slate-500: #aeb3b9;
|
||||
--color-slate-600: #c2c6cb;
|
||||
--color-slate-700: #d6d9dd;
|
||||
--color-slate-800: #e5e7eb;
|
||||
--color-slate-900: #f3f4f6;
|
||||
background: #272a2d;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] body {
|
||||
background: #272a2d;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .bg-white {
|
||||
background-color: #303337;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] input,
|
||||
html[data-theme='dark'] textarea,
|
||||
html[data-theme='dark'] select {
|
||||
background-color: #383b3f;
|
||||
color: #f1f2f3;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .bg-slate-900\/50 {
|
||||
background-color: rgb(70 74 79 / 72%);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .shadow-sm,
|
||||
html[data-theme='dark'] .shadow-xl {
|
||||
--tw-shadow: 0 8px 24px rgb(109 114 120 / 12%);
|
||||
box-shadow: var(--tw-shadow);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .mpm-module-tile:hover {
|
||||
border-color: #686d73;
|
||||
background-color: #3b3f43;
|
||||
box-shadow: 0 10px 24px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
@@ -19,13 +19,14 @@ export class ApiError extends Error {
|
||||
|
||||
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
|
||||
function readCsrfToken(): string | null {
|
||||
let csrfToken: string | null = null;
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const [name, ...value] = part.trim().split('=');
|
||||
if (name === 'mpm_csrf') {
|
||||
return decodeURIComponent(value.join('='));
|
||||
csrfToken = decodeURIComponent(value.join('='));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
/** Callback, der bei einem 401-Fehler aufgerufen wird (Auth-Context setzt ihn). */
|
||||
@@ -63,11 +64,14 @@ export async function apiRequest<TResponse>(
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (response.status === 401 && unauthorizedHandler) {
|
||||
// A 401 from a feature endpoint may be request-specific. Let the auth
|
||||
// context verify the session through /auth/me before clearing the user.
|
||||
if (response.status === 401 && path !== '/api/v1/auth/me' && unauthorizedHandler) {
|
||||
unauthorizedHandler();
|
||||
}
|
||||
|
||||
@@ -92,4 +96,4 @@ export async function apiRequest<TResponse>(
|
||||
}
|
||||
|
||||
return (await response.json()) as TResponse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,11 +121,63 @@ export async function revokeModuleAccess(userId: string, moduleId: string): Prom
|
||||
|
||||
/** Liest das CSRF-Token aus dem nicht-HttpOnly-Cookie. */
|
||||
function readCsrfToken(): string | null {
|
||||
let csrfToken: string | null = null;
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const [name, ...value] = part.trim().split('=');
|
||||
if (name === 'mpm_csrf') {
|
||||
return decodeURIComponent(value.join('='));
|
||||
csrfToken = decodeURIComponent(value.join('='));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
export interface MarketplaceProvider {
|
||||
provider: 'github' | 'gitea' | 'forgejo';
|
||||
label: string;
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
accountLogin: string | null;
|
||||
baseUrl: string;
|
||||
callbackUrl: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceRepository {
|
||||
owner: string;
|
||||
repository: string;
|
||||
htmlUrl: string;
|
||||
description: string;
|
||||
defaultBranch: string;
|
||||
installed: boolean;
|
||||
}
|
||||
|
||||
export async function fetchMarketplaceRepositories(provider: MarketplaceProvider['provider']): Promise<MarketplaceRepository[]> {
|
||||
return apiRequest<MarketplaceRepository[]>(`/api/v1/marketplace/repositories/${provider}`);
|
||||
}
|
||||
|
||||
export async function installMarketplaceRepository(
|
||||
provider: MarketplaceProvider['provider'],
|
||||
owner: string,
|
||||
repository: string,
|
||||
): Promise<Module> {
|
||||
const response = await apiRequest<{ module: unknown }>(
|
||||
`/api/v1/marketplace/repositories/${provider}/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}/install`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return moduleSchema.parse(response.module);
|
||||
}
|
||||
|
||||
export async function fetchMarketplaceProviders(): Promise<MarketplaceProvider[]> {
|
||||
return apiRequest<MarketplaceProvider[]>('/api/v1/marketplace/providers');
|
||||
}
|
||||
|
||||
export async function beginMarketplaceConnection(provider: MarketplaceProvider['provider']): Promise<string> {
|
||||
const response = await apiRequest<{ authorizationUrl: string }>(
|
||||
`/api/v1/marketplace/connections/${provider}/start`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return response.authorizationUrl;
|
||||
}
|
||||
|
||||
export async function disconnectMarketplaceProvider(provider: MarketplaceProvider['provider']): Promise<void> {
|
||||
await apiRequest(`/api/v1/marketplace/connections/${provider}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ import { SystemStatusPage } from './features/admin/system-status-page';
|
||||
import { UsersPage } from './features/admin/users-page';
|
||||
import { ModulesPage } from './features/admin/modules-page';
|
||||
import { AuditPage } from './features/admin/audit-page';
|
||||
import { SettingsPage } from './features/admin/settings-page';
|
||||
import { ProfilePage } from './features/profile/profile-page';
|
||||
import { ForbiddenPage, NotFoundPage } from './pages/error-pages';
|
||||
import './index.css';
|
||||
|
||||
document.documentElement.dataset.theme =
|
||||
localStorage.getItem('mpm-theme') === 'dark' ? 'dark' : 'light';
|
||||
|
||||
/** TanStack Query: keine automatischen Refetches bei Fensterfokus. */
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -36,7 +37,6 @@ function AppRoutes(): ReactNode {
|
||||
|
||||
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={<RequireAdmin><UsersPage /></RequireAdmin>}
|
||||
@@ -53,10 +53,6 @@ function AppRoutes(): ReactNode {
|
||||
path="/admin/audit"
|
||||
element={<RequireAdmin><AuditPage /></RequireAdmin>}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={<RequireAdmin><SettingsPage /></RequireAdmin>}
|
||||
/>
|
||||
<Route path="/403" element={<ForbiddenPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -83,4 +79,4 @@ createRoot(rootElement).render(
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user