Scopes.tsx raw

   1  import {
   2    ArrowDownUpIcon,
   3    BrickWallIcon,
   4    ChevronsUpDownIcon,
   5    LucideIcon,
   6    MoveDownIcon,
   7    SquarePenIcon,
   8  } from "lucide-react";
   9  import React from "react";
  10  import { Button } from "src/components/ui/button";
  11  import { Checkbox } from "src/components/ui/checkbox";
  12  import { Label } from "src/components/ui/label";
  13  import {
  14    Sheet,
  15    SheetClose,
  16    SheetContent,
  17    SheetFooter,
  18    SheetHeader,
  19    SheetTitle,
  20  } from "src/components/ui/sheet";
  21  import { cn } from "src/lib/utils";
  22  import {
  23    READ_ONLY_SCOPES,
  24    Scope,
  25    WalletCapabilities,
  26    scopeDescriptions,
  27  } from "src/types";
  28  
  29  const scopeGroups = ["full_access", "read_only", "isolated", "custom"] as const;
  30  type ScopeGroup = (typeof scopeGroups)[number];
  31  type ScopeGroupIconMap = { [key in ScopeGroup]: LucideIcon };
  32  
  33  const scopeGroupIconMap: ScopeGroupIconMap = {
  34    full_access: ArrowDownUpIcon,
  35    read_only: MoveDownIcon,
  36    isolated: BrickWallIcon,
  37    custom: SquarePenIcon,
  38  };
  39  
  40  const scopeGroupTitle: Record<ScopeGroup, string> = {
  41    full_access: "Full Access",
  42    read_only: "Read Only",
  43    isolated: "Isolated App",
  44    custom: "Custom",
  45  };
  46  
  47  const scopeGroupDescriptions: Record<ScopeGroup, string> = {
  48    full_access: "Send and receive payments",
  49    read_only: "Receive payments and view history",
  50    isolated: "Separate wallet with isolated balance",
  51    custom: "Define specific permissions",
  52  };
  53  
  54  interface ScopesProps {
  55    capabilities: WalletCapabilities;
  56    scopes: Scope[];
  57    isolated: boolean;
  58    onScopesChanged: (scopes: Scope[], isolated: boolean) => void;
  59  }
  60  
  61  const Scopes: React.FC<ScopesProps> = ({
  62    capabilities,
  63    scopes,
  64    isolated,
  65    onScopesChanged,
  66  }) => {
  67    const [isSheetOpen, setSheetOpen] = React.useState(false);
  68    const fullAccessScopes: Scope[] = React.useMemo(() => {
  69      return [...capabilities.scopes];
  70    }, [capabilities.scopes]);
  71  
  72    const readOnlyScopes: Scope[] = React.useMemo(() => {
  73      return capabilities.scopes.filter((scope) =>
  74        READ_ONLY_SCOPES.includes(scope)
  75      );
  76    }, [capabilities.scopes]);
  77  
  78    const isolatedScopes: Scope[] = React.useMemo(() => {
  79      const isolatedScopes: Scope[] = [
  80        "pay_invoice",
  81        "get_balance",
  82        "get_info",
  83        "make_invoice",
  84        "lookup_invoice",
  85        "list_transactions",
  86        "notifications",
  87      ];
  88  
  89      return capabilities.scopes.filter((scope) =>
  90        isolatedScopes.includes(scope)
  91      );
  92    }, [capabilities.scopes]);
  93  
  94    const [scopeGroup, setScopeGroup] = React.useState<ScopeGroup>(() => {
  95      if (
  96        isolated &&
  97        scopes.length === isolatedScopes.length &&
  98        scopes.every((scope) => isolatedScopes.includes(scope))
  99      ) {
 100        return "isolated";
 101      }
 102      if (
 103        scopes.length === fullAccessScopes.length &&
 104        scopes.every((scope) => fullAccessScopes.includes(scope))
 105      ) {
 106        return "full_access";
 107      }
 108      if (
 109        scopes.length === readOnlyScopes.length &&
 110        readOnlyScopes.every((readOnlyScope) => scopes.includes(readOnlyScope))
 111      ) {
 112        return "read_only";
 113      }
 114  
 115      return "custom";
 116    });
 117  
 118    const handleScopeGroupChange = (scopeGroup: ScopeGroup) => {
 119      setScopeGroup(scopeGroup);
 120      switch (scopeGroup) {
 121        case "full_access":
 122          onScopesChanged(fullAccessScopes, false);
 123          break;
 124        case "read_only":
 125          onScopesChanged(readOnlyScopes, false);
 126          break;
 127        case "isolated":
 128          onScopesChanged(isolatedScopes, true);
 129          break;
 130        default: {
 131          onScopesChanged([], false);
 132          break;
 133        }
 134      }
 135    };
 136  
 137    const handleScopeChange = (scope: Scope) => {
 138      let newScopes = [...scopes];
 139      if (newScopes.includes(scope)) {
 140        newScopes = newScopes.filter((existing) => existing !== scope);
 141      } else {
 142        newScopes.push(scope);
 143      }
 144  
 145      onScopesChanged(newScopes, isolated);
 146    };
 147  
 148    const ActiveScopeGroupIcon = scopeGroupIconMap[scopeGroup];
 149  
 150    return (
 151      <>
 152        <Sheet open={isSheetOpen} onOpenChange={setSheetOpen}>
 153          <SheetContent>
 154            <SheetHeader>
 155              <SheetTitle>Choose wallet permissions</SheetTitle>
 156            </SheetHeader>
 157            <div className="flex flex-col gap-4 px-6">
 158              {scopeGroups.map((sg, index) => {
 159                const ScopeGroupIcon = scopeGroupIconMap[sg];
 160                return (
 161                  <button
 162                    type="button"
 163                    key={index}
 164                    className={cn(
 165                      "flex gap-3 items-center rounded-lg border-2 cursor-pointer p-4",
 166                      scopeGroup == sg ? "border-primary" : "border-muted"
 167                    )}
 168                    onClick={() => {
 169                      handleScopeGroupChange(sg);
 170                      setSheetOpen(false);
 171                    }}
 172                  >
 173                    <ScopeGroupIcon className="shrink-0 size-5 text-muted-foreground" />
 174                    <div className="flex flex-col gap-0.5 text-left">
 175                      <p className="text-sm font-medium">{scopeGroupTitle[sg]}</p>
 176                      <span className="text-xs text-muted-foreground">
 177                        {scopeGroupDescriptions[sg]}
 178                      </span>
 179                    </div>
 180                  </button>
 181                );
 182              })}
 183            </div>
 184            <SheetFooter>
 185              <Button type="submit">Save changes</Button>
 186              <SheetClose asChild>
 187                <Button variant="outline">Close</Button>
 188              </SheetClose>
 189            </SheetFooter>
 190          </SheetContent>
 191        </Sheet>
 192        <button
 193          type="button"
 194          className="flex items-center gap-2 rounded-lg border cursor-pointer p-4 w-full"
 195          onClick={() => {
 196            setSheetOpen(true);
 197          }}
 198        >
 199          <ActiveScopeGroupIcon className="shrink-0 size-5 text-muted-foreground" />
 200          <div className="flex flex-col gap-0.5 text-left">
 201            <p className="text-sm font-medium">{scopeGroupTitle[scopeGroup]}</p>
 202            <span className="text-xs text-muted-foreground">
 203              {scopeGroupDescriptions[scopeGroup]}
 204            </span>
 205          </div>
 206          <ChevronsUpDownIcon className="ml-auto shrink-0 size-4 text-muted-foreground" />
 207        </button>
 208  
 209        {scopeGroup == "custom" && (
 210          <div className="mb-2">
 211            <p className="font-medium text-sm mt-4">Isolation</p>
 212            <div className="flex items-center mt-2">
 213              <Checkbox
 214                id="isolated"
 215                className="mr-2"
 216                onCheckedChange={() => onScopesChanged(scopes, !isolated)}
 217                checked={isolated}
 218              />
 219              <Label htmlFor="isolated" className="cursor-pointer">
 220                Isolate this app's balance and transactions
 221              </Label>
 222            </div>
 223            <p className="font-medium text-sm mt-4">Authorize the app to:</p>
 224            <ul className="flex flex-col w-full mt-2">
 225              {capabilities.scopes.map((scope, index) => {
 226                return (
 227                  <li
 228                    key={index}
 229                    className={cn(
 230                      "w-full",
 231                      scope == "pay_invoice" ? "order-last" : ""
 232                    )}
 233                  >
 234                    <div className="flex items-center mb-2">
 235                      <Checkbox
 236                        id={scope}
 237                        className="mr-2"
 238                        onCheckedChange={() => handleScopeChange(scope)}
 239                        checked={scopes.includes(scope)}
 240                      />
 241                      <Label htmlFor={scope} className="cursor-pointer">
 242                        {scopeDescriptions[scope]}
 243                      </Label>
 244                    </div>
 245                  </li>
 246                );
 247              })}
 248            </ul>
 249          </div>
 250        )}
 251      </>
 252    );
 253  };
 254  
 255  export default Scopes;
 256