# Grants editor

> The grants on a drive item, inherited ones included, with a picker to share it with a member or team at a role.

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

## Install the block

```bash
npx better-supabase add drive
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add badge button dialog field select skeleton
```

From `apps/examples/nextjs/src/features/drive/components/grants-editor.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/drive/components/grants-editor.tsx):

```tsx
/** Loads an item's grants; `open` starts a fresh read, so call it with the dialog. */
export function useDriveSharing(id: string) {
  const load = useAction(loadDriveSharing);

  return {
    ...load,
    open: () => {
      load.reset();
      void load.run({ id });
    },
  };
}

/**
 * Who can open a drive item: its grants, inherited ones from the folders
 * above included, and a picker to share it with a member or a team.
 */
export function GrantsEditor({
  id,
  name,
  load,
  open,
  onOpenChange,
}: {
  id: string;
  name: string;
  load: ReturnType<typeof useDriveSharing>;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}) {
  const t = useExtracted("drive");
  const errorMessage = useErrorMessage();

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent data-recipe="grants-editor">
        <DialogHeader>
          <DialogTitle>{t("Share {name}", { name })}</DialogTitle>
          <DialogDescription>
            {t("Members and teams you add here can open it.")}
          </DialogDescription>
        </DialogHeader>
        {load.error ? (
          <div
            role="alert"
            className="flex items-center justify-between gap-2 text-sm text-destructive"
          >
            {errorMessage(load.error)}
            <Button
              variant="outline"
              size="sm"
              onClick={() => {
                void load.run({ id });
              }}
            >
              {t("Retry")}
            </Button>
          </div>
        ) : load.data === undefined ? (
          <div className="flex flex-col gap-2" aria-busy="true">
            <Skeleton className="h-8 w-full" />
            <Skeleton className="h-8 w-full" />
          </div>
        ) : (
          <SharingPanel
            id={id}
            grants={load.data.grants}
            targets={load.data.targets}
            onChange={() => {
              void load.run({ id });
            }}
          />
        )}
      </DialogContent>
    </Dialog>
  );
}

function SharingPanel({
  id,
  grants,
  targets,
  onChange,
}: {
  id: string;
  grants: readonly SharingGrant[];
  targets: readonly SharingTarget[];
  onChange: () => void;
}) {
  const t = useExtracted("drive");
  const fieldId = useId();
  const roleLabel = useRoleLabel();
  const errorMessage = useErrorMessage();

  const onError = (error: Parameters<typeof errorMessage>[0]) => {
    toast.error(errorMessage(error));
  };

  const share = useAction(shareDriveItem, { onSuccess: onChange, onError });
  const unshare = useAction(unshareDriveItem, { onSuccess: onChange, onError });

  const granted = new Set(grants.map(keyOf));

  const options = targets
    .filter((target) => !granted.has(keyOf(target)))
    .map((target) => ({
      value: keyOf(target),
      label:
        target.type === "team"
          ? t("{name} (team)", { name: target.name })
          : target.name,
    }));

  const [picked, setPicked] = useState<string | null>(null);
  const [role, setRole] = useState<DriveRole>("viewer");

  const target = targets.find(
    (each) => keyOf(each) === (picked ?? options[0]?.value),
  );

  return (
    <div className="flex flex-col gap-4">
      {grants.length === 0 ? (
        <p className="text-sm text-muted-foreground">
          {t("Not shared yet. Pick a member or team below.")}
        </p>
      ) : (
        <ul className="flex flex-col gap-2">
          {grants.map((grant) => (
            <li
              key={`${keyOf(grant)}:${grant.inherited ? "up" : "here"}`}
              className="flex items-center justify-between gap-2"
            >
              <div className="flex min-w-0 flex-col gap-1">
                <span className="truncate text-sm">{grant.name}</span>
                {grant.inherited ? (
                  <span className="text-xs text-muted-foreground">
                    {t("From a folder above")}
                  </span>
                ) : null}
              </div>
              <div className="flex items-center gap-2">
                <Badge variant="secondary">{roleLabel(grant.role)}</Badge>
                {!grant.inherited && grant.type !== "role" ? (
                  <Button
                    size="icon-sm"
                    variant="ghost"
                    disabled={unshare.pending}
                    aria-label={t("Stop sharing with {name}", {
                      name: grant.name,
                    })}
                    onClick={() => {
                      if (grant.type !== "role") {
                        void unshare.run({
                          id,
                          principal: { type: grant.type, id: grant.id },
                        });
                      }
                    }}
                  >
                    <XIcon />
                  </Button>
                ) : null}
              </div>
            </li>
          ))}
        </ul>
      )}
      {target === undefined ? null : (
        <form
          className="flex items-end gap-2"
          onSubmit={(event) => {
            event.preventDefault();
            void share.run({
              id,
              principal: { type: target.type, id: target.id },
              role,
            });
            setPicked(null);
          }}
        >
          <Field className="min-w-0 flex-1">
            <FieldLabel htmlFor={`${fieldId}-target`}>
              {t("Share with")}
            </FieldLabel>
            <Select
              value={keyOf(target)}
              items={options}
              onValueChange={(value) => {
                const match = options.find((option) => option.value === value);

                if (match) {
                  setPicked(match.value);
                }
              }}
            >
              <SelectTrigger id={`${fieldId}-target`} className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {options.map((option) => (
                  <SelectItem key={option.value} value={option.value}>
                    {option.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </Field>
          <Field className="w-36">
            <FieldLabel htmlFor={`${fieldId}-role`}>{t("Access")}</FieldLabel>
            <Select
              value={role}
              items={DRIVE_ROLES.map((each) => ({
                value: each,
                label: roleLabel(each),
              }))}
              onValueChange={(value) => {
                if (isRole(value)) {
                  setRole(value);
                }
              }}
            >
              <SelectTrigger id={`${fieldId}-role`} className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {DRIVE_ROLES.map((each) => (
                  <SelectItem key={each} value={each}>
                    {roleLabel(each)}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </Field>
          <Button type="submit" disabled={share.pending}>
            <UserPlusIcon />
            {t("Share")}
          </Button>
        </form>
      )}
    </div>
  );
}
```

## Prompt

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

### Web

```text
Build "Grants editor" in a React web app that uses shadcn/ui components. The grants on a drive item, inherited ones included, with a picker to share it with a member or team at a role.

It uses the better-supabase `drive` block (Drive). 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 drive` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add badge button dialog field select skeleton`.
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/drive/components/grants-editor.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/grants-editor.md
- Block docs: https://bettersupabase.com/docs/blocks/drive
```
