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:
1 parent
793bb8c366
commit
fdc348f06c
7 files changed
+646
No files matched your search
@@ -21,6 +21,7 @@ import { LicensesView } from './views/licenses'
|
|||||||
import { AgencyView } from './views/agency'
|
import { AgencyView } from './views/agency'
|
||||||
import { ResellerPortalView } from './views/reseller-portal'
|
import { ResellerPortalView } from './views/reseller-portal'
|
||||||
import { InstancesView } from './views/instances'
|
import { InstancesView } from './views/instances'
|
||||||
|
import { ReposView } from './views/repos'
|
||||||
import { ContractsView } from './views/contracts'
|
import { ContractsView } from './views/contracts'
|
||||||
import { ExpensesView } from './views/expenses'
|
import { ExpensesView } from './views/expenses'
|
||||||
import { FinanceView } from './views/finance'
|
import { FinanceView } from './views/finance'
|
||||||
@@ -100,6 +101,7 @@ export function AppShell() {
|
|||||||
{view === 'agency' && <AgencyView />}
|
{view === 'agency' && <AgencyView />}
|
||||||
{view === 'reseller_portal' && <ResellerPortalView />}
|
{view === 'reseller_portal' && <ResellerPortalView />}
|
||||||
{view === 'instances' && <InstancesView />}
|
{view === 'instances' && <InstancesView />}
|
||||||
|
{view === 'repos' && <ReposView />}
|
||||||
{view === 'contracts' && <ContractsView />}
|
{view === 'contracts' && <ContractsView />}
|
||||||
{view === 'expenses' && <ExpensesView />}
|
{view === 'expenses' && <ExpensesView />}
|
||||||
{view === 'finance' && <FinanceView />}
|
{view === 'finance' && <FinanceView />}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Server,
|
Server,
|
||||||
Store,
|
Store,
|
||||||
|
GitBranch,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import {
|
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: 'agency', label: 'Reseller', icon: Users, hint: 'Reseller-Bewerbungen & Rabatte' },
|
||||||
{ id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, hint: 'Selbstbedienung: Kunden, Lizenzen, Tickets' },
|
{ id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, hint: 'Selbstbedienung: Kunden, Lizenzen, Tickets' },
|
||||||
{ id: 'instances', label: 'Instanzen', icon: Server, hint: 'Tenant-Instanzen & Provisioning' },
|
{ 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: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
||||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
||||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
Landmark,
|
Landmark,
|
||||||
Server,
|
Server,
|
||||||
Store,
|
Store,
|
||||||
|
GitBranch,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import { translate } from '@/lib/i18n'
|
import { translate } from '@/lib/i18n'
|
||||||
@@ -72,6 +73,7 @@ const NAV: {
|
|||||||
{ id: 'agency', label: 'Reseller', icon: Users, permission: 'admin' },
|
{ id: 'agency', label: 'Reseller', icon: Users, permission: 'admin' },
|
||||||
{ id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, permission: 'admin' },
|
{ id: 'reseller_portal', label: 'Reseller-Portal', icon: Store, permission: 'admin' },
|
||||||
{ id: 'instances', label: 'Instanzen', icon: Server, 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: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
||||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
|||||||
agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' },
|
agency: { title: 'Reseller', crumb: 'Betrieb / Reseller' },
|
||||||
reseller_portal: { title: 'Reseller-Portal', crumb: 'Betrieb / Reseller-Portal' },
|
reseller_portal: { title: 'Reseller-Portal', crumb: 'Betrieb / Reseller-Portal' },
|
||||||
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
|
instances: { title: 'Instanzen', crumb: 'Betrieb / Instanzen' },
|
||||||
|
repos: { title: 'Repos', crumb: 'Betrieb / Repos' },
|
||||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||||
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
||||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ const de: Dict = {
|
|||||||
'nav.agency': 'Reseller',
|
'nav.agency': 'Reseller',
|
||||||
'nav.reseller_portal': 'Reseller-Portal',
|
'nav.reseller_portal': 'Reseller-Portal',
|
||||||
'nav.instances': 'Instanzen',
|
'nav.instances': 'Instanzen',
|
||||||
|
'nav.repos': 'Repos',
|
||||||
'nav.contracts': 'Verträge',
|
'nav.contracts': 'Verträge',
|
||||||
'nav.expenses': 'Ausgaben',
|
'nav.expenses': 'Ausgaben',
|
||||||
'nav.finance': 'Finanzen',
|
'nav.finance': 'Finanzen',
|
||||||
@@ -90,6 +91,7 @@ const en: Dict = {
|
|||||||
'nav.agency': 'Reseller',
|
'nav.agency': 'Reseller',
|
||||||
'nav.reseller_portal': 'Reseller Portal',
|
'nav.reseller_portal': 'Reseller Portal',
|
||||||
'nav.instances': 'Instances',
|
'nav.instances': 'Instances',
|
||||||
|
'nav.repos': 'Repos',
|
||||||
'nav.contracts': 'Contracts',
|
'nav.contracts': 'Contracts',
|
||||||
'nav.expenses': 'Expenses',
|
'nav.expenses': 'Expenses',
|
||||||
'nav.finance': 'Finance',
|
'nav.finance': 'Finance',
|
||||||
@@ -145,6 +147,7 @@ const tr: Dict = {
|
|||||||
'nav.agency': 'Bayi',
|
'nav.agency': 'Bayi',
|
||||||
'nav.reseller_portal': 'Bayi Portal',
|
'nav.reseller_portal': 'Bayi Portal',
|
||||||
'nav.instances': 'Sunucular',
|
'nav.instances': 'Sunucular',
|
||||||
|
'nav.repos': 'Repolar',
|
||||||
'nav.contracts': 'Sözleşmeler',
|
'nav.contracts': 'Sözleşmeler',
|
||||||
'nav.expenses': 'Giderler',
|
'nav.expenses': 'Giderler',
|
||||||
'nav.finance': 'Finans',
|
'nav.finance': 'Finans',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type ViewId =
|
|||||||
| 'agency'
|
| 'agency'
|
||||||
| 'reseller_portal'
|
| 'reseller_portal'
|
||||||
| 'instances'
|
| 'instances'
|
||||||
|
| 'repos'
|
||||||
| 'contracts'
|
| 'contracts'
|
||||||
| 'expenses'
|
| 'expenses'
|
||||||
| 'finance'
|
| 'finance'
|
||||||
|
|||||||
Reference in new issue
Block a user