Commit 23445681 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: add partner management and federation dialogs

parent 861de8a9
Loading
Loading
Loading
Loading
+76 −0
Changes for portal-gui/src/components/federation/confirm-delete-federation-dialog.tsx: 76 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useState } from 'react';

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Partner } from '@/types/federation';

export function ConfirmDeleteFederationDialog({
  partner,
  onOpenChange,
  onConfirm,
}: {
  partner: Partner;
  onOpenChange: (open: boolean) => void;
  onConfirm: () => Promise<void>;
}) {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleConfirm = async () => {
    setPending(true);
    setError(null);
    try {
      await onConfirm();
      onOpenChange(false);
    } catch (err) {
      setError(
        err instanceof Error ? err.message : 'Failed to delete federation.',
      );
    } finally {
      setPending(false);
    }
  };

  return (
    <AlertDialog
      open
      onOpenChange={(open) => {
        if (!pending) onOpenChange(open);
      }}
    >
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Delete federation?</AlertDialogTitle>
          <AlertDialogDescription>
            Ends our federation with "{partner.name}", including the zones we
            subscribed to. The partner registration stays.
          </AlertDialogDescription>
        </AlertDialogHeader>

        {error && <p className="text-sm text-destructive">{error}</p>}

        <AlertDialogFooter>
          <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
          <AlertDialogAction
            onClick={(event) => {
              event.preventDefault();
              void handleConfirm();
            }}
            disabled={pending}
            className="text-destructive-foreground bg-destructive hover:bg-destructive/90"
          >
            {pending ? 'Deleting…' : 'Delete'}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
+132 −0
Changes for portal-gui/src/components/federation/create-federation-dialog.tsx: 132 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useState } from 'react';

import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { federationRequestSchema } from '@/features/federation/federation-schema';
import type { FederationRequestData, Partner } from '@/types/federation';

function localNow(): string {
  const now = new Date();
  now.setSeconds(0, 0);
  return new Date(now.getTime() - now.getTimezoneOffset() * 60_000)
    .toISOString()
    .slice(0, 16);
}

export function CreateFederationDialog({
  partner,
  onOpenChange,
  onCreate,
}: {
  partner: Partner;
  onOpenChange: (open: boolean) => void;
  onCreate: (request: FederationRequestData) => Promise<void>;
}) {
  const [initialDate, setInitialDate] = useState(localNow);
  const [partnerStatusLink, setPartnerStatusLink] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [errors, setErrors] = useState<string[]>([]);

  const handleSubmit = async () => {
    const result = federationRequestSchema.safeParse({
      initialDate,
      partnerStatusLink: partnerStatusLink.trim(),
    });
    if (!result.success) {
      setErrors(result.error.issues.map((issue) => issue.message));
      return;
    }
    setSubmitting(true);
    setErrors([]);
    try {
      await onCreate({
        initialDate: new Date(result.data.initialDate).toISOString(),
        partnerStatusLink: result.data.partnerStatusLink,
      });
      onOpenChange(false);
    } catch (err) {
      setErrors([err instanceof Error ? err.message : 'The request failed.']);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <Dialog
      open
      onOpenChange={(value) => {
        if (!submitting) onOpenChange(value);
      }}
    >
      <DialogContent className="sm:max-w-lg">
        <DialogHeader>
          <DialogTitle>Create Federation</DialogTitle>
          <DialogDescription>
            Our Federation Manager asks {partner.name} to federate with us. Our
            own identity is filled in by the Federation Manager.
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-4 py-2">
          <div className="space-y-2">
            <Label htmlFor="federation-initial-date">Initial date *</Label>
            <Input
              id="federation-initial-date"
              type="datetime-local"
              value={initialDate}
              onChange={(e) => setInitialDate(e.target.value)}
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="federation-status-link">
              Partner status link *
            </Label>
            <Input
              id="federation-status-link"
              className="font-mono"
              value={partnerStatusLink}
              onChange={(e) => setPartnerStatusLink(e.target.value)}
              placeholder="https://fm.oop.example.com/operatorplatform/federation/v1/callbacks/partner-status"
            />
            <p className="text-xs text-muted-foreground">
              Where the partner sends status updates about this federation.
            </p>
          </div>

          {errors.length > 0 && (
            <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
              {errors.join(' ')}
            </div>
          )}
        </div>

        <DialogFooter>
          <Button
            type="button"
            variant="outline"
            disabled={submitting}
            onClick={() => onOpenChange(false)}
          >
            Cancel
          </Button>
          <Button
            type="button"
            disabled={!partnerStatusLink.trim() || submitting}
            onClick={() => void handleSubmit()}
          >
            {submitting ? 'Creating…' : 'Create federation'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
+18 −0
Changes for portal-gui/src/components/federation/detail-fields.tsx: 18 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { ReactNode } from 'react';

export function DetailFields({
  fields,
}: {
  fields: Record<string, ReactNode>;
}) {
  return (
    <dl className="grid gap-3 sm:grid-cols-2">
      {Object.entries(fields).map(([label, value]) => (
        <div key={label} className="min-w-0">
          <dt className="text-muted-foreground">{label}</dt>
          <dd className="font-mono text-xs break-all">{value}</dd>
        </div>
      ))}
    </dl>
  );
}
+18 −0
Changes for portal-gui/src/components/federation/federation-state-badge.tsx: 18 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { Loader2 } from 'lucide-react';

import { Badge } from '@/components/ui/badge';
import type { FederationState } from '@/types/federation';

export function FederationStateBadge({ state }: { state: FederationState }) {
  if (state === 'loading') {
    return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
  }
  if (state === 'unknown') {
    return <span className="text-muted-foreground">—</span>;
  }
  return (
    <Badge variant={state === 'federated' ? 'default' : 'outline'}>
      {state === 'federated' ? 'Federated' : 'Not federated'}
    </Badge>
  );
}
+23 −12
Changes for portal-gui/src/components/federation/partner-columns.tsx: 23 added lines, 12 removed lines.
Original line number Diff line number Diff line
@@ -5,16 +5,20 @@ import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import type { Partner } from '@/types/federation';
import type { Partner, PartnerRow } from '@/types/federation';
import { FederationStateBadge } from './federation-state-badge';
import { PartnerStatusBadge } from './partner-status-badge';

export function getPartnerColumns(
  onDetails: (partner: Partner) => void,
  onFederation: (partner: Partner) => void,
  onZones: (partner: Partner) => void,
): ColumnDef<Partner>[] {
  onEdit: (partner: Partner) => void,
): ColumnDef<PartnerRow>[] {
  return [
    {
      id: 'drag',
@@ -54,6 +58,14 @@ export function getPartnerColumns(
      cell: ({ row }) => <PartnerStatusBadge status={row.original.status} />,
      size: 120,
    },
    {
      id: 'federation',
      header: 'Federation',
      cell: ({ row }) => (
        <FederationStateBadge state={row.original.federation} />
      ),
      size: 140,
    },
    {
      id: 'endpoint',
      header: 'EWBI endpoint',
@@ -67,16 +79,6 @@ export function getPartnerColumns(
      ),
      size: 320,
    },
    {
      id: 'credentials',
      header: 'Outbound credentials',
      cell: ({ row }) => (
        <span className="text-muted-foreground">
          {row.original.our_client_id ? 'Configured' : 'Not set'}
        </span>
      ),
      size: 160,
    },
    {
      id: 'actions',
      enableHiding: false,
@@ -91,9 +93,18 @@ export function getPartnerColumns(
            <DropdownMenuItem onClick={() => onDetails(row.original)}>
              Details
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => onFederation(row.original)}>
              Federation
            </DropdownMenuItem>
            {row.original.federation === 'federated' && (
              <DropdownMenuItem onClick={() => onZones(row.original)}>
                Zones
              </DropdownMenuItem>
            )}
            <DropdownMenuSeparator />
            <DropdownMenuItem onClick={() => onEdit(row.original)}>
              Edit
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      ),
Loading