feat: marketplace modules and isolated container management

This commit is contained in:
ayde64
2026-10-07 23:18:02 +02:00
parent 564f7def1c
commit 7b121fa908
68 changed files with 2560 additions and 699 deletions

View File

@@ -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">
&Ouml;ffentliche Repositories verbundener Forge-Konten. Installiere den Standard-Branch direkt; MPM pr&uuml;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 &ouml;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>
);
}