Permissions.tsx raw

   1  import { AlertTriangleIcon, CoinsIcon, TimerIcon } from "lucide-react";
   2  import React from "react";
   3  import BudgetAmountSelect from "src/components/BudgetAmountSelect";
   4  import BudgetRenewalSelect from "src/components/BudgetRenewalSelect";
   5  import ExpirySelect from "src/components/ExpirySelect";
   6  import Scopes from "src/components/Scopes";
   7  import { Badge } from "src/components/ui/badge";
   8  import { Switch } from "src/components/ui/switch";
   9  import {
  10    DEFAULT_APP_BUDGET_RENEWAL,
  11    DEFAULT_APP_BUDGET_SATS,
  12  } from "src/constants";
  13  import { cn } from "src/lib/utils";
  14  import {
  15    AppPermissions,
  16    BudgetRenewalType,
  17    Scope,
  18    WalletCapabilities,
  19    scopeDescriptions,
  20    scopeIconMap,
  21  } from "src/types";
  22  
  23  interface PermissionsProps {
  24    capabilities: WalletCapabilities;
  25    permissions: AppPermissions;
  26    setPermissions?: React.Dispatch<React.SetStateAction<AppPermissions>>;
  27    readOnly?: boolean;
  28  }
  29  
  30  const Permissions: React.FC<PermissionsProps> = ({
  31    capabilities,
  32    permissions,
  33    setPermissions,
  34    readOnly,
  35  }) => {
  36    const [showBudgetOptions, setShowBudgetOptions] = React.useState(
  37      permissions.scopes.includes("pay_invoice") && permissions.maxAmountSat > 0
  38    );
  39    const [showExpiryOptions, setShowExpiryOptions] = React.useState(
  40      !!permissions.expiresAt
  41    );
  42  
  43    const handlePermissionsChange = React.useCallback(
  44      (changedPermissions: Partial<AppPermissions>) => {
  45        setPermissions?.((currentPermissions) => ({
  46          ...currentPermissions,
  47          ...changedPermissions,
  48        }));
  49      },
  50      [setPermissions]
  51    );
  52  
  53    const onScopesChanged = React.useCallback(
  54      (scopes: Scope[], isolated: boolean) => {
  55        handlePermissionsChange({ scopes, isolated });
  56      },
  57      [handlePermissionsChange]
  58    );
  59  
  60    const handleBudgetMaxAmountSatChange = React.useCallback(
  61      (amountSat: number) => {
  62        handlePermissionsChange({ maxAmountSat: amountSat });
  63      },
  64      [handlePermissionsChange]
  65    );
  66  
  67    const handleBudgetRenewalChange = React.useCallback(
  68      (budgetRenewal: BudgetRenewalType) => {
  69        handlePermissionsChange({ budgetRenewal });
  70      },
  71      [handlePermissionsChange]
  72    );
  73  
  74    const handleExpiryChange = React.useCallback(
  75      (expiryDate?: Date) => {
  76        handlePermissionsChange({ expiresAt: expiryDate });
  77      },
  78      [handlePermissionsChange]
  79    );
  80  
  81    return (
  82      <div className={cn("space-y-4", !readOnly && "max-w-lg")}>
  83        {!readOnly ? (
  84          <Scopes
  85            capabilities={capabilities}
  86            scopes={permissions.scopes}
  87            isolated={permissions.isolated}
  88            onScopesChanged={onScopesChanged}
  89          />
  90        ) : (
  91          <>
  92            <p className="text-sm font-medium mb-2">This app is authorized to:</p>
  93            <div className="flex flex-wrap gap-2 mb-4">
  94              {[...permissions.scopes].map((scope) => {
  95                const PermissionIcon = scopeIconMap[scope];
  96                return (
  97                  <Badge
  98                    variant="secondary"
  99                    key={scope}
 100                    className={cn(
 101                      "flex items-center font-normal py-1 rounded-full px-3"
 102                    )}
 103                  >
 104                    <PermissionIcon className="mr-1 size-4" />
 105                    <p className="text-sm">{scopeDescriptions[scope]}</p>
 106                  </Badge>
 107                );
 108              })}
 109            </div>
 110          </>
 111        )}
 112  
 113        {/* We skip read only component here as budget is shown in AppUsage */}
 114        {permissions.scopes.includes("pay_invoice") && !readOnly && (
 115          <div className="rounded-lg border p-4">
 116            <div className="flex items-center justify-between">
 117              <label
 118                htmlFor="budget-toggle"
 119                className="flex items-center gap-2 cursor-pointer"
 120              >
 121                <CoinsIcon className="size-5 text-muted-foreground" />
 122                <div className="flex flex-col gap-0.5">
 123                  <p className="text-sm font-medium">Budget</p>
 124                  <p className="text-xs text-muted-foreground">
 125                    Limit how much this app can spend
 126                  </p>
 127                </div>
 128              </label>
 129              <Switch
 130                id="budget-toggle"
 131                checked={showBudgetOptions}
 132                onCheckedChange={(checked) => {
 133                  if (checked) {
 134                    handleBudgetRenewalChange(DEFAULT_APP_BUDGET_RENEWAL);
 135                    handleBudgetMaxAmountSatChange(DEFAULT_APP_BUDGET_SATS);
 136                  } else {
 137                    handleBudgetRenewalChange("never");
 138                    handleBudgetMaxAmountSatChange(0);
 139                  }
 140                  setShowBudgetOptions(checked);
 141                }}
 142              />
 143            </div>
 144            {showBudgetOptions && (
 145              <div className="mt-4">
 146                <BudgetRenewalSelect
 147                  value={permissions.budgetRenewal}
 148                  onChange={handleBudgetRenewalChange}
 149                />
 150                <BudgetAmountSelect
 151                  valueSat={permissions.maxAmountSat}
 152                  onChange={handleBudgetMaxAmountSatChange}
 153                />
 154              </div>
 155            )}
 156          </div>
 157        )}
 158  
 159        {!readOnly ? (
 160          <div className="rounded-lg border p-4">
 161            <div className="flex items-center justify-between">
 162              <label
 163                htmlFor="expiry-toggle"
 164                className="flex items-center gap-2 cursor-pointer"
 165              >
 166                <TimerIcon className="size-5 text-muted-foreground" />
 167                <div className="flex flex-col gap-0.5">
 168                  <p className="text-sm font-medium">Expiration</p>
 169                  <p className="text-xs text-muted-foreground">
 170                    Automatically expire this connection
 171                  </p>
 172                </div>
 173              </label>
 174              <Switch
 175                id="expiry-toggle"
 176                checked={showExpiryOptions}
 177                onCheckedChange={(checked) => {
 178                  if (checked) {
 179                    const defaultExpiry = new Date();
 180                    defaultExpiry.setFullYear(defaultExpiry.getFullYear() + 1);
 181                    defaultExpiry.setHours(23, 59, 59);
 182                    handleExpiryChange(defaultExpiry);
 183                  } else {
 184                    handleExpiryChange(undefined);
 185                  }
 186                  setShowExpiryOptions(checked);
 187                }}
 188              />
 189            </div>
 190            {showExpiryOptions && (
 191              <div className="mt-4">
 192                <ExpirySelect
 193                  value={permissions.expiresAt}
 194                  onChange={handleExpiryChange}
 195                />
 196              </div>
 197            )}
 198          </div>
 199        ) : (
 200          <div>
 201            <p className="text-sm font-medium mb-2">Connection expiry</p>
 202            <p className="text-muted-foreground text-sm">
 203              {permissions.expiresAt
 204                ? new Date(permissions.expiresAt).toString()
 205                : "This app will never expire"}
 206            </p>
 207          </div>
 208        )}
 209  
 210        {permissions.scopes.includes("superuser") && (
 211          <>
 212            <div className="flex items-center gap-2 mt-4">
 213              <AlertTriangleIcon className="size-4" />
 214              <p className="text-sm font-medium">
 215                This app can create other app connections
 216              </p>
 217            </div>
 218            <p className="text-muted-foreground text-sm">
 219              Make sure to set budgets on connections created by this app.
 220            </p>
 221          </>
 222        )}
 223      </div>
 224    );
 225  };
 226  
 227  export default Permissions;
 228