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

feat: implement API client creation and management with dialogs for secret...

feat: implement API client creation and management with dialogs for secret display and product selection
parent dde478df
Loading
Loading
Loading
Loading
+60 −0
Changes for portal-gui/src/components/api-clients/api-client-secret-dialog.tsx: 60 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { useConnection } from '@/hooks/useConnection';
import type { CreatedApiClient } from '@/types/api-clients';
import { CopyValue } from './copy-value';

// The secret exists only in this dialog's props: closing it drops it for good.
export function ApiClientSecretDialog({
  client,
  onClose,
}: {
  client: CreatedApiClient;
  onClose: () => void;
}) {
  const connection = useConnection();

  return (
    <Dialog open onOpenChange={(open) => !open && onClose()}>
      <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
        <DialogHeader>
          <DialogTitle>{client.name} created</DialogTitle>
          <DialogDescription>
            Copy the secret now: it won&apos;t be shown again. If you lose it,
            delete this client and create a new one.
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-4 py-2">
          <CopyValue label="Client ID" value={client.client_id} />
          <CopyValue label="Client secret" value={client.client_secret} />
          {connection && (
            <>
              <CopyValue
                label="Get a token"
                value={`curl -s -X POST '${connection.token_endpoint}' \\\n  -d grant_type=client_credentials \\\n  -d client_id=${client.client_id} \\\n  -d client_secret=${client.client_secret}`}
              />
              {connection.oeg_base_url && (
                <CopyValue
                  label="Then call the APIs at"
                  value={connection.oeg_base_url}
                />
              )}
            </>
          )}
        </div>

        <DialogFooter>
          <Button onClick={onClose}>I&apos;ve saved the secret</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
+31 −0
Changes for portal-gui/src/components/api-clients/copy-value.tsx: 31 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useState } from 'react';

import { Button } from '@/components/ui/button';

export function CopyValue({ label, value }: { label: string; value: string }) {
  const [copied, setCopied] = useState(false);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(value);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch {
      // Clipboard access can be refused: the value stays on screen to select.
    }
  };

  return (
    <div className="space-y-1.5">
      <span className="text-sm font-medium">{label}</span>
      <div className="flex items-start gap-2">
        <code className="min-w-0 flex-1 rounded-md border bg-muted px-3 py-2 font-mono text-xs break-all whitespace-pre-wrap">
          {value}
        </code>
        <Button type="button" variant="outline" size="sm" onClick={copy}>
          {copied ? 'Copied' : 'Copy'}
        </Button>
      </div>
    </div>
  );
}
+161 −0
Changes for portal-gui/src/components/api-clients/create-api-client-dialog.tsx: 161 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useState } from 'react';

import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useApiProducts } from '@/hooks/useApiProducts';
import { useSingleFlight } from '@/hooks/useSingleFlight';
import { createApiClient } from '@/services/api-clients/api-clients';
import type { CreatedApiClient } from '@/types/api-clients';

const ALERT =
  'rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive';

export function CreateApiClientDialog({
  onClose,
  onCreated,
}: {
  onClose: () => void;
  onCreated: (client: CreatedApiClient) => void;
}) {
  const { products, loading, error: productsFailed } = useApiProducts();
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  // Until the developer changes anything, every product they hold is ticked.
  const [chosen, setChosen] = useState<Set<string> | null>(null);
  const [submitting, setSubmitting] = useState(false);
  const [message, setMessage] = useState('');
  const once = useSingleFlight();

  const selected =
    chosen ?? new Set(products.filter((p) => p.held).map((p) => p.name));
  const holdsNone = products.length > 0 && !products.some((p) => p.held);

  const toggle = (product: string, on: boolean) => {
    const next = new Set(selected);
    if (on) next.add(product);
    else next.delete(product);
    setChosen(next);
  };

  const submit = () =>
    once(async () => {
      setSubmitting(true);
      setMessage('');
      try {
        onCreated(
          await createApiClient({
            name: name.trim(),
            description: description.trim(),
            products: [...selected],
          }),
        );
      } catch (err) {
        setMessage(err instanceof Error ? err.message : 'The request failed.');
        setSubmitting(false);
      }
    });

  return (
    <Dialog open onOpenChange={(open) => !open && !submitting && onClose()}>
      <DialogContent className="sm:max-w-lg">
        <DialogHeader>
          <DialogTitle>Create API client</DialogTitle>
          <DialogDescription>
            A client ID and secret for calling the APIs from your own systems.
            The products can&apos;t be changed afterwards.
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-4 py-2">
          <div className="space-y-2">
            <Label htmlFor="api-client-name">Name *</Label>
            <Input
              id="api-client-name"
              aria-required
              value={name}
              onChange={(e) => setName(e.target.value)}
              placeholder="Fleet tracker"
              maxLength={100}
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="api-client-description">Description</Label>
            <Input
              id="api-client-description"
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              maxLength={500}
            />
          </div>

          <fieldset className="space-y-2">
            <legend className="text-sm font-medium">Products</legend>
            {loading && (
              <p className="text-sm text-muted-foreground">Loading…</p>
            )}
            {productsFailed && (
              <p role="alert" className={ALERT}>
                Could not load the products.
              </p>
            )}
            {holdsNone && (
              <p role="alert" className={ALERT}>
                You don&apos;t hold any API product yet. Ask an operator to
                grant you access.
              </p>
            )}
            {products.map((product) => (
              <div key={product.name} className="flex items-start gap-2">
                <Checkbox
                  id={`product-${product.name}`}
                  checked={selected.has(product.name)}
                  disabled={!product.held}
                  onCheckedChange={(on) => toggle(product.name, on === true)}
                />
                <Label
                  htmlFor={`product-${product.name}`}
                  className="font-normal"
                >
                  {product.name}
                  {!product.held && (
                    <span className="block text-xs text-muted-foreground">
                      Not granted to you. Ask an operator for access.
                    </span>
                  )}
                </Label>
              </div>
            ))}
          </fieldset>

          {message && (
            <p role="alert" className={ALERT}>
              {message}
            </p>
          )}
        </div>

        <DialogFooter>
          <Button variant="outline" onClick={onClose} disabled={submitting}>
            Cancel
          </Button>
          <Button
            onClick={submit}
            disabled={submitting || name.trim() === '' || selected.size === 0}
          >
            Create
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
+36 −1
Changes for portal-gui/src/features/api-clients/api-clients.tsx: 36 added lines, 1 removed line.
Original line number Diff line number Diff line
import { useState } from 'react';
import { Plus } from 'lucide-react';

import {
  ApiClientsFootnote,
  ApiClientsNotice,
} from '@/components/api-clients/api-clients-notices';
import { apiClientColumns } from '@/components/api-clients/api-client-columns';
import { ApiClientSecretDialog } from '@/components/api-clients/api-client-secret-dialog';
import { CreateApiClientDialog } from '@/components/api-clients/create-api-client-dialog';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/data-table/data-table';
import { EmptyState } from '@/components/common/empty-state';
import { PageContent } from '@/components/common/page-content';
import { PageHeader } from '@/components/common/page-header';
import { PageState } from '@/components/common/page-state';
import { useApiClients } from '@/hooks/useApiClients';
import type { CreatedApiClient } from '@/types/api-clients';

export function ApiClientsPage() {
  const { clients, loading, error, unavailable, retry } = useApiClients();
  const [creating, setCreating] = useState(false);
  const [created, setCreated] = useState<CreatedApiClient | null>(null);

  return (
    <>
      <PageHeader />
      <PageHeader
        actions={
          !unavailable && (
            <Button onClick={() => setCreating(true)}>
              <Plus className="mr-2 h-4 w-4" />
              Create API client
            </Button>
          )
        }
      />

      <PageContent>
        <ApiClientsNotice />
@@ -46,6 +64,23 @@ export function ApiClientsPage() {
          </PageState>
        )}
      </PageContent>

      {creating && (
        <CreateApiClientDialog
          onClose={() => setCreating(false)}
          onCreated={(client) => {
            setCreating(false);
            setCreated(client);
            retry();
          }}
        />
      )}
      {created && (
        <ApiClientSecretDialog
          client={created}
          onClose={() => setCreated(null)}
        />
      )}
    </>
  );
}
+29 −0
Changes for portal-gui/src/hooks/useApiProducts.ts: 29 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useEffect, useState } from 'react';

import { listApiProducts } from '@/services/api-clients/api-clients';
import type { ApiProduct } from '@/types/api-clients';

export function useApiProducts() {
  const [products, setProducts] = useState<ApiProduct[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);

  useEffect(() => {
    let cancelled = false;
    listApiProducts()
      .then((rows) => {
        if (!cancelled) setProducts(rows);
      })
      .catch(() => {
        if (!cancelled) setError(true);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  return { products, loading, error };
}
Loading