# Share link dialog

> A dialog that creates an expiring link to a record and revokes it.

- Block: [share-links](https://bettersupabase.com/blocks/share-links.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/share-link-dialog

## Install the block

```bash
npx better-supabase add share-links
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert-dialog badge button card dialog empty field input select
```

From `apps/examples/nextjs/src/features/customers/components/share-link-dialog.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/customers/components/share-link-dialog.tsx):

```tsx
type ExpiryDays = 1 | 7 | 30 | 0;

const isExpiry = (value: unknown): value is ExpiryDays =>
  value === 1 || value === 7 || value === 30 || value === 0;

type LinkState = "active" | "expired" | "revoked";

const LINK_VARIANT = {
  active: "success-light",
  expired: "warning-light",
  revoked: "secondary",
} as const satisfies Record<LinkState, string>;

function stateOf(link: ShareLinkRow): LinkState {
  if (link.revokedAt !== null) {
    return "revoked";
  }

  return link.expired ? "expired" : "active";
}

/** View links to the customer: create one, see who can still open them, revoke. */
export function ShareLinkDialog({
  customerId,
  links,
  canCreate,
}: {
  customerId: string;
  links: Loaded<readonly ShareLinkRow[]>;
  canCreate: boolean;
}) {
  const t = useExtracted("customers");

  return (
    <Card size="sm" data-recipe="share-link-dialog">
      <CardHeader>
        <CardTitle>{t("Share links")}</CardTitle>
        {canCreate && links.kind === "ok" && links.data.length > 0 ? (
          <CardAction>
            <CreateShareLink customerId={customerId} />
          </CardAction>
        ) : null}
      </CardHeader>
      <CardContent>
        {links.kind === "error" ? (
          <RecordLoadError title={t("The share links didn't load")} />
        ) : links.data.length === 0 ? (
          <Empty className="border">
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <Link2Icon />
              </EmptyMedia>
              <EmptyTitle>{t("No share links")}</EmptyTitle>
              <EmptyDescription>
                {t("A link lets someone outside the team view this customer.")}
              </EmptyDescription>
            </EmptyHeader>
            {canCreate ? (
              <EmptyContent>
                <CreateShareLink customerId={customerId} />
              </EmptyContent>
            ) : null}
          </Empty>
        ) : (
          <ShareLinkList links={links.data} canRevoke={canCreate} />
        )}
      </CardContent>
    </Card>
  );
}

function ShareLinkList({
  links,
  canRevoke,
}: {
  links: readonly ShareLinkRow[];
  canRevoke: boolean;
}) {
  const t = useExtracted("customers");
  const format = useFormatter();

  const label: Record<LinkState, string> = {
    active: t("Active"),
    expired: t("Expired"),
    revoked: t("Revoked"),
  };

  return (
    <ul className="flex flex-col divide-y" data-testid="share-links">
      {links.map((link) => {
        const state = stateOf(link);

        return (
          <li
            key={link.id}
            className="flex items-center justify-between gap-2 py-2"
          >
            <div className="flex min-w-0 flex-col gap-1">
              <div className="flex items-center gap-2">
                <Badge variant={LINK_VARIANT[state]}>{label[state]}</Badge>
                <span className="text-sm">{t("View only")}</span>
              </div>
              <span className="text-xs text-muted-foreground">
                {link.expiresAt === null
                  ? t("Never expires")
                  : t("Expires {date}", {
                      date: format.dateTime(link.expiresAt, {
                        dateStyle: "medium",
                        timeStyle: "short",
                      }),
                    })}
              </span>
            </div>
            {canRevoke && state === "active" ? (
              <RevokeShareLink id={link.id} />
            ) : null}
          </li>
        );
      })}
    </ul>
  );
}

function CreateShareLink({ customerId }: { customerId: string }) {
  const t = useExtracted("customers");
  const fieldId = useId();
  const errorMessage = useErrorMessage();
  const [open, setOpen] = useState(false);
  const [expiresInDays, setExpiresInDays] = useState<ExpiryDays>(7);
  const [url, setUrl] = useState<string | null>(null);

  const create = useAction(createShareLink, {
    onSuccess: ({ token }) => {
      setUrl(`${window.location.origin}/share/${token}`);
    },
  });

  const expiries = [
    { value: 1, label: t("1 day") },
    { value: 7, label: t("7 days") },
    { value: 30, label: t("30 days") },
    { value: 0, label: t("Never") },
  ];

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        setOpen(next);

        if (!next) {
          setUrl(null);
          create.reset();
        }
      }}
    >
      <DialogTrigger render={<Button size="sm" />}>
        <Link2Icon />
        {t("Create link")}
      </DialogTrigger>
      <DialogContent>
        {url ? (
          <div className="grid gap-6">
            <DialogHeader>
              <DialogTitle>{t("Link created")}</DialogTitle>
              <DialogDescription>
                {t(
                  "Copy it now. It is shown once; revoke it here to close it.",
                )}
              </DialogDescription>
            </DialogHeader>
            <div className="flex gap-2">
              <Input
                value={url}
                readOnly
                aria-label={t("Share link")}
                data-testid="share-link-url"
              />
              <Button
                variant="outline"
                size="icon"
                aria-label={t("Copy link")}
                onClick={() => {
                  void navigator.clipboard
                    .writeText(url)
                    .then(() => toast.success(t("Link copied")));
                }}
              >
                <CopyIcon />
              </Button>
            </div>
            <DialogFooter>
              <DialogClose render={<Button />}>{t("Done")}</DialogClose>
            </DialogFooter>
          </div>
        ) : (
          <form
            className="grid gap-6"
            onSubmit={(event) => {
              event.preventDefault();
              void create.run({ customerId, expiresInDays });
            }}
          >
            <DialogHeader>
              <DialogTitle>{t("Share this customer")}</DialogTitle>
              <DialogDescription>
                {t(
                  "Anyone with the link can view the customer until it expires.",
                )}
              </DialogDescription>
            </DialogHeader>
            <Field data-invalid={create.error ? true : undefined}>
              <FieldLabel htmlFor={`${fieldId}-expiry`}>
                {t("Expires after")}
              </FieldLabel>
              <Select
                value={expiresInDays}
                items={expiries}
                onValueChange={(value) => {
                  if (isExpiry(value)) {
                    setExpiresInDays(value);
                  }
                }}
              >
                <SelectTrigger id={`${fieldId}-expiry`} className="w-40">
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {expiries.map((each) => (
                    <SelectItem key={each.value} value={each.value}>
                      {each.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              {create.error ? (
                <FieldError>{errorMessage(create.error)}</FieldError>
              ) : null}
            </Field>
            <DialogFooter>
              <DialogClose render={<Button type="button" variant="outline" />}>
                {t("Cancel")}
              </DialogClose>
              <Button type="submit" disabled={create.pending}>
                {t("Create link")}
              </Button>
            </DialogFooter>
          </form>
        )}
      </DialogContent>
    </Dialog>
  );
}

function RevokeShareLink({ id }: { id: string }) {
  const t = useExtracted("customers");
  const errorMessage = useErrorMessage();

  const revoke = useAction(revokeShareLink, {
    onSuccess: () => {
      toast.success(t("Link revoked"));
    },
    onError: (error) => {
      toast.error(errorMessage(error));
    },
  });

  return (
    <AlertDialog>
      <AlertDialogTrigger
        render={
          <Button size="sm" variant="outline" disabled={revoke.pending} />
        }
      >
        {t("Revoke")}
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{t("Revoke this link?")}</AlertDialogTitle>
          <AlertDialogDescription>
            {t("Anyone who has it can no longer open the customer.")}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>{t("Cancel")}</AlertDialogCancel>
          <AlertDialogAction
            variant="destructive"
            onClick={() => {
              void revoke.run({ id });
            }}
          >
            {t("Revoke")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

/** The share links card while its rows load. */
export function ShareLinkDialogSkeleton() {
  return <RecordCardSkeleton rows={2} />;
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Share link dialog" in a React web app that uses shadcn/ui components. A dialog that creates an expiring link to a record and revokes it.

It uses the better-supabase `share-links` block (Share links). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add share-links` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert-dialog badge button card dialog empty field input select`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/customers/components/share-link-dialog.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/share-link-dialog.md
- Block docs: https://bettersupabase.com/docs/blocks/share-links
```
