feat: repos management UI — admin can add/remove repos, set env templates

- ReposView: table with display name, Gitea repo, product, visibility, status, instances
- RepoFormDialog: create/edit with owner, repo, displayName, description, product,
  isPublic switch, isActive switch, envTemplate textarea (JSON)
- DeleteRepoDialog: AlertDialog with instance count check (409 → blocked)
- Integration: store ViewId, sidebar nav (GitBranch icon), top-bar title,
  command-menu, i18n (de/en/tr)
- Empty state: 'Keine Repos freigegeben'
This commit is contained in:
Z User committed 2026-10-29 16:47:20 +00:00
1 parent 793bb8c366
commit fdc348f06c
7 files changed
+646

No files matched your search

+2
View File
@@ -21,6 +21,7 @@ import { LicensesView } from './views/licenses'
import { AgencyView } from './views/agency'
import { ResellerPortalView } from './views/reseller-portal'
import { InstancesView } from './views/instances'
import { ReposView } from './views/repos'
import { ContractsView } from './views/contracts'
import { ExpensesView } from './views/expenses'
import { FinanceView } from './views/finance'
@@ -100,6 +101,7 @@ export function AppShell() {
{view === 'agency' && <AgencyView />}
{view === 'reseller_portal' && <ResellerPortalView />}
{view === 'instances' && <InstancesView />}
{view === 'repos' && <ReposView />}
{view === 'contracts' && <ContractsView />}
{view === 'expenses' && <ExpensesView />}
{view === 'finance' && <FinanceView />}
+2
View File
@@ -28,6 +28,7 @@ import {
Database,
Server,
Store,
GitBranch,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import {
@@ -54,6 +55,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
{ id: 'agency', label: 'Reseller', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' },
{ id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, hint: 'Selbstbedienung: Kunden, Lizenzen, Tickets' },
{ id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' },
{ id: 'repos', label: 'Repos', icon: GitBranch, hint: 'Gitea-Repository-Verwaltung' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
+2
View File
@@ -30,6 +30,7 @@ import {
Landmark,
Server,
Store,
GitBranch,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { translate } from '@/lib/i18n'
@@ -72,6 +73,7 @@ const NAV: {
{ id: 'agency', label: 'Reseller', icon: Users, permission: 'admin' },
{ id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, permission: 'admin' },
{ id: 'instances', label: 'Instanzen', icon: Server, permission: 'admin' },
{ id: 'repos', label: 'Repos', icon: GitBranch, permission: 'admin' },
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
+1
View File
@@ -31,6 +31,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' },
reseller_portal: { title: 'Reseller-Portal', crumb: 'Betrieb / Reseller-Portal' },
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
repos: { title: 'Repos', crumb: 'Betrieb / Repos' },
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
+635
View File
@@ -0,0 +1,635 @@
'use client'
import { useEffect, useState } from 'react'
import {
GitBranch,
Plus,
Pencil,
Trash2,
Loader2,
Globe,
Users,
CheckCircle2,
CircleDashed,
ExternalLink,
} from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { PageHeader, EmptyState } from '../primitives'
import { formatDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
// ---------------------------------------------------------------------------
// Types — mirror the AllowedRepo shape returned by /api/aos/repos
// ---------------------------------------------------------------------------
interface AllowedRepo {
id: string
giteaOwner: string
giteaRepo: string
displayName: string
description: string | null
product: string
isPublic: boolean
isActive: boolean
envTemplate: string | null
createdAt: string
_count: { assignments: number; instances: number }
}
interface ReposData {
repos: AllowedRepo[]
}
// ---------------------------------------------------------------------------
// Main view
// ---------------------------------------------------------------------------
export function ReposView() {
const { data, loading, refetch } = useFetch<ReposData>('/api/aos/repos')
const [dialogOpen, setDialogOpen] = useState(false)
const [editTarget, setEditTarget] = useState<AllowedRepo | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AllowedRepo | null>(null)
const openCreate = () => {
setEditTarget(null)
setDialogOpen(true)
}
const openEdit = (repo: AllowedRepo) => {
setEditTarget(repo)
setDialogOpen(true)
}
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-64" />
</div>
)
}
const { repos } = data
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Repos"
description="Verwaltung der freigegebenen Gitea-Repositories für Tenant-Instanzen."
actions={
<Button size="sm" onClick={openCreate}>
<Plus className="size-4" />
Repo hinzufügen
</Button>
}
/>
{repos.length === 0 ? (
<EmptyState
icon={GitBranch}
title="Keine Repos freigegeben"
description="Füge ein Repo hinzu, damit Kunden Instanzen erstellen können."
action={
<Button size="sm" onClick={openCreate}>
<Plus className="size-4" />
Repo hinzufügen
</Button>
}
/>
) : (
<Card className="gap-0 overflow-hidden p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Gitea Repo</TableHead>
<TableHead>Produkt</TableHead>
<TableHead>Sichtbarkeit</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Instanzen</TableHead>
<TableHead className="text-right">Aktionen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{repos.map((repo) => (
<RepoRow
key={repo.id}
repo={repo}
onEdit={() => openEdit(repo)}
onDelete={() => setDeleteTarget(repo)}
/>
))}
</TableBody>
</Table>
</Card>
)}
<RepoFormDialog
open={dialogOpen}
onOpenChange={(o) => {
setDialogOpen(o)
if (!o) setEditTarget(null)
}}
repo={editTarget}
onSaved={refetch}
/>
<DeleteRepoDialog
repo={deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null)
}}
onDeleted={refetch}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Repo row
// ---------------------------------------------------------------------------
function RepoRow({
repo,
onEdit,
onDelete,
}: {
repo: AllowedRepo
onEdit: () => void
onDelete: () => void
}) {
return (
<TableRow>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{repo.displayName}</span>
{repo.description && (
<span className="line-clamp-1 max-w-xs text-[11px] text-muted-foreground">
{repo.description}
</span>
)}
</div>
</TableCell>
<TableCell>
<a
href={`https://git.dfmpanel.eu/${repo.giteaOwner}/${repo.giteaRepo}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-mono text-xs hover:underline"
>
{repo.giteaOwner}/{repo.giteaRepo}
<ExternalLink className="size-3 text-muted-foreground" />
</a>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-foreground/20">
{repo.product}
</Badge>
</TableCell>
<TableCell>
<VisibilityBadge isPublic={repo.isPublic} assignments={repo._count.assignments} />
</TableCell>
<TableCell>
<StatusBadge isActive={repo.isActive} />
</TableCell>
<TableCell className="text-right">
<span className="tabular-nums text-sm">{repo._count.instances}</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={onEdit}
aria-label="Bearbeiten"
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-foreground"
onClick={onDelete}
aria-label="Entfernen"
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)
}
// ---------------------------------------------------------------------------
// Badges
// ---------------------------------------------------------------------------
function VisibilityBadge({ isPublic, assignments }: { isPublic: boolean; assignments: number }) {
if (isPublic) {
return (
<Badge variant="outline" className="gap-1 border-foreground/30">
<Globe className="size-3" />
Public
</Badge>
)
}
return (
<Badge variant="outline" className="gap-1 border-foreground/20">
<Users className="size-3" />
Assigned
{assignments > 0 && (
<span className="text-[10px] text-muted-foreground">· {assignments}</span>
)}
</Badge>
)
}
function StatusBadge({ isActive }: { isActive: boolean }) {
return (
<Badge
variant="outline"
className={cn(
'gap-1',
isActive ? 'border-foreground/40' : 'border-foreground/20 text-muted-foreground'
)}
>
{isActive ? <CheckCircle2 className="size-3" /> : <CircleDashed className="size-3" />}
{isActive ? 'Aktiv' : 'Inaktiv'}
</Badge>
)
}
// ---------------------------------------------------------------------------
// Create / Edit dialog
// ---------------------------------------------------------------------------
interface RepoFormValue {
giteaOwner: string
giteaRepo: string
displayName: string
description: string
product: string
isPublic: boolean
isActive: boolean
envTemplate: string
}
const EMPTY_FORM: RepoFormValue = {
giteaOwner: '',
giteaRepo: '',
displayName: '',
description: '',
product: '',
isPublic: false,
isActive: true,
envTemplate: '',
}
function RepoFormDialog({
open,
onOpenChange,
repo,
onSaved,
}: {
open: boolean
onOpenChange: (o: boolean) => void
repo: AllowedRepo | null
onSaved: () => void
}) {
const isEdit = !!repo
const [form, setForm] = useState<RepoFormValue>(EMPTY_FORM)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open) {
if (repo) {
setForm({
giteaOwner: repo.giteaOwner,
giteaRepo: repo.giteaRepo,
displayName: repo.displayName,
description: repo.description ?? '',
product: repo.product,
isPublic: repo.isPublic,
isActive: repo.isActive,
envTemplate: repo.envTemplate ?? '',
})
} else {
setForm(EMPTY_FORM)
}
}
}, [open, repo])
const submit = async () => {
if (!form.giteaOwner.trim()) { toast.error('Gitea Owner eingeben'); return }
if (!form.giteaRepo.trim()) { toast.error('Gitea Repo eingeben'); return }
if (!form.displayName.trim()) { toast.error('Display Name eingeben'); return }
if (!form.product.trim()) { toast.error('Produkt eingeben'); return }
// Validate envTemplate JSON if provided.
const env = form.envTemplate.trim()
if (env) {
try {
const parsed = JSON.parse(env)
if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
throw new Error('not an object')
}
} catch {
toast.error('Env Template ist kein gültiges JSON-Objekt')
return
}
}
setSaving(true)
const t = toast.loading(isEdit ? 'Repo wird aktualisiert…' : 'Repo wird hinzugefügt…')
try {
if (isEdit) {
const payload: Record<string, unknown> = {
displayName: form.displayName.trim(),
description: form.description.trim() || undefined,
isPublic: form.isPublic,
isActive: form.isActive,
envTemplate: env || undefined,
}
const r = await fetch(`/api/aos/repos/${repo!.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
})
const res = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(res.error || 'update failed')
toast.success('Repo aktualisiert', { id: t })
} else {
const payload: Record<string, unknown> = {
giteaOwner: form.giteaOwner.trim(),
giteaRepo: form.giteaRepo.trim(),
displayName: form.displayName.trim(),
description: form.description.trim() || undefined,
product: form.product.trim(),
isPublic: form.isPublic,
envTemplate: env || undefined,
}
const r = await fetch('/api/aos/repos', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
})
const res = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(res.error || 'create failed')
toast.success('Repo hinzugefügt', { id: t })
}
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Speichern fehlgeschlagen', { id: t })
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Repo bearbeiten' : 'Repo hinzufügen'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Repo-Konfiguration aktualisieren. Gitea-Owner und -Repo können nachträglich nicht geändert werden.'
: 'Erlaubt das Provisioning von Instanzen aus diesem Gitea-Repository.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Gitea Owner *</Label>
<Input
value={form.giteaOwner}
onChange={(e) => setForm((f) => ({ ...f, giteaOwner: e.target.value }))}
placeholder="dfmsolutions"
disabled={isEdit}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Gitea Repo *</Label>
<Input
value={form.giteaRepo}
onChange={(e) => setForm((f) => ({ ...f, giteaRepo: e.target.value }))}
placeholder="restov1"
disabled={isEdit}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Display Name *</Label>
<Input
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
placeholder="Pizzeria RESTO (restov1)"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Beschreibung</Label>
<Textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Kurze Beschreibung des Produkts"
className="min-h-[60px] text-xs"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Produkt *</Label>
<Input
value={form.product}
onChange={(e) => setForm((f) => ({ ...f, product: e.target.value }))}
placeholder="restov1, aos-cms, …"
/>
</div>
{/* Public switch */}
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2.5">
<div className="space-y-0.5">
<Label className="text-xs">Öffentlich (Public)</Label>
<p className="text-[11px] text-muted-foreground">
Wenn aktiv, sehen alle Kunden dieses Repo. Andernfalls nur explizit zugewiesene Firmen.
</p>
</div>
<Switch
checked={form.isPublic}
onCheckedChange={(v) => setForm((f) => ({ ...f, isPublic: v }))}
/>
</div>
{/* Active switch — edit only */}
{isEdit && (
<div className="flex items-start justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2.5">
<div className="space-y-0.5">
<Label className="text-xs">Aktiv</Label>
<p className="text-[11px] text-muted-foreground">
Inaktive Repos sind im Provisioning-Menü ausgeblendet, werden aber nicht gelöscht.
</p>
</div>
<Switch
checked={form.isActive}
onCheckedChange={(v) => setForm((f) => ({ ...f, isActive: v }))}
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">Env Template (JSON)</Label>
<Textarea
value={form.envTemplate}
onChange={(e) => setForm((f) => ({ ...f, envTemplate: e.target.value }))}
placeholder='{"APP_MODE":"demo","RESTO_QR_SECRET":"…"}'
className="min-h-[80px] font-mono text-xs"
/>
<p className="text-[11px] text-muted-foreground">
Env-Vorlage wird beim Provisioning automatisch eingefügt. Überschreibt nicht die
AOS-Lizenz-Variablen.
</p>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
Abbrechen
</Button>
<Button onClick={submit} disabled={saving} className="gap-1.5">
{saving ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
{isEdit ? 'Speichern' : 'Hinzufügen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ---------------------------------------------------------------------------
// Delete dialog
// ---------------------------------------------------------------------------
function DeleteRepoDialog({
repo,
onOpenChange,
onDeleted,
}: {
repo: AllowedRepo | null
onOpenChange: (o: boolean) => void
onDeleted: () => void
}) {
const [busy, setBusy] = useState(false)
const open = !!repo
const confirm = async () => {
if (!repo) return
setBusy(true)
const t = toast.loading('Repo wird entfernt…')
try {
const r = await fetch(`/api/aos/repos/${repo.id}`, { method: 'DELETE' })
const res = await r.json().catch(() => ({}))
if (!r.ok) {
// 409 = active instances block deletion
if (r.status === 409 && typeof res.instances === 'number') {
throw new Error(
`Repo hat ${res.instances} aktive Instanz(en) — Löschen blockiert.`
)
}
throw new Error(res.error || 'Löschen fehlgeschlagen')
}
toast.success('Repo entfernt', { id: t })
onOpenChange(false)
onDeleted()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Löschen fehlgeschlagen', { id: t })
} finally {
setBusy(false)
}
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Trash2 className="size-4 text-muted-foreground" />
Repo wirklich entfernen?
</AlertDialogTitle>
<AlertDialogDescription>
{repo
? `Bestehende Instanzen werden nicht gelöscht. Das Repo „${repo.displayName}" wird aus der Freigabe-Liste entfernt und steht nicht mehr für neue Provisionings zur Verfügung.`
: 'Bestehende Instanzen werden nicht gelöscht.'}
</AlertDialogDescription>
</AlertDialogHeader>
{repo && (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
<div className="flex items-center gap-2">
<GitBranch className="size-3.5 text-muted-foreground" />
<span className="font-mono">{repo.giteaOwner}/{repo.giteaRepo}</span>
<span className="ml-auto text-muted-foreground">
erstellt {formatDate(repo.createdAt)}
</span>
</div>
<div className="mt-1.5 flex items-center gap-2 text-muted-foreground">
<span>{repo._count.instances} Instanzen</span>
<span>·</span>
<span>{repo._count.assignments} Zuweisungen</span>
</div>
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
void confirm()
}}
disabled={busy}
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2 className="size-3.5" />}
Entfernen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+3
View File
@@ -31,6 +31,7 @@ const de: Dict = {
'nav.agency': 'Reseller',
'nav.reseller_portal': 'Reseller-Portal',
'nav.instances': 'Instanzen',
'nav.repos': 'Repos',
'nav.contracts': 'Verträge',
'nav.expenses': 'Ausgaben',
'nav.finance': 'Finanzen',
@@ -90,6 +91,7 @@ const en: Dict = {
'nav.agency': 'Reseller',
'nav.reseller_portal': 'Reseller Portal',
'nav.instances': 'Instances',
'nav.repos': 'Repos',
'nav.contracts': 'Contracts',
'nav.expenses': 'Expenses',
'nav.finance': 'Finance',
@@ -145,6 +147,7 @@ const tr: Dict = {
'nav.agency': 'Bayi',
'nav.reseller_portal': 'Bayi Portal',
'nav.instances': 'Sunucular',
'nav.repos': 'Repolar',
'nav.contracts': 'Sözleşmeler',
'nav.expenses': 'Giderler',
'nav.finance': 'Finans',
+1
View File
@@ -16,6 +16,7 @@ export type ViewId =
| 'agency'
| 'reseller_portal'
| 'instances'
| 'repos'
| 'contracts'
| 'expenses'
| 'finance'