AppDetails.tsx raw

   1  import React from "react";
   2  import { Link, useLocation, useParams } from "react-router";
   3  
   4  import {
   5    App,
   6    AppPermissions,
   7    UpdateAppRequest,
   8    WalletCapabilities,
   9  } from "src/types";
  10  
  11  import { handleRequestError } from "src/utils/handleRequestError";
  12  import { request } from "src/utils/request"; // build the project for this to appear
  13  
  14  import {
  15    CheckCircleIcon,
  16    ChevronDownIcon,
  17    EllipsisIcon,
  18    InfoIcon,
  19    PlusIcon,
  20    SquarePenIcon,
  21    SquareStackIcon,
  22    UnplugIcon,
  23  } from "lucide-react";
  24  import { toast } from "sonner";
  25  import AppAvatar from "src/components/AppAvatar";
  26  import AppHeader from "src/components/AppHeader";
  27  import { AboutAppCard } from "src/components/connections/AboutAppCard";
  28  import { AppLinksCard } from "src/components/connections/AppLinksCard";
  29  import { AppTransactionList } from "src/components/connections/AppTransactionList";
  30  import { AppUsage } from "src/components/connections/AppUsage";
  31  import { ConnectionDetailsModal } from "src/components/connections/ConnectionDetailsModal";
  32  import { DisconnectApp } from "src/components/connections/DisconnectApp";
  33  import { getAppStoreApp } from "src/components/connections/SuggestedAppData";
  34  import Loading from "src/components/Loading";
  35  import Permissions from "src/components/Permissions";
  36  import ResponsiveButton from "src/components/ResponsiveButton";
  37  import {
  38    AlertDialog,
  39    AlertDialogCancel,
  40    AlertDialogContent,
  41    AlertDialogDescription,
  42    AlertDialogFooter,
  43    AlertDialogTitle,
  44  } from "src/components/ui/alert-dialog";
  45  import { Badge } from "src/components/ui/badge";
  46  import { Button } from "src/components/ui/button";
  47  import {
  48    Card,
  49    CardContent,
  50    CardHeader,
  51    CardTitle,
  52  } from "src/components/ui/card";
  53  import {
  54    DropdownMenu,
  55    DropdownMenuContent,
  56    DropdownMenuGroup,
  57    DropdownMenuItem,
  58    DropdownMenuSeparator,
  59    DropdownMenuTrigger,
  60  } from "src/components/ui/dropdown-menu";
  61  import { Input } from "src/components/ui/input";
  62  import {
  63    ALBY_ACCOUNT_APP_NAME,
  64    SUBWALLET_APPSTORE_APP_ID,
  65  } from "src/constants";
  66  import { useAlbyMe } from "src/hooks/useAlbyMe";
  67  import { useApp } from "src/hooks/useApp";
  68  import { useAppsForAppStoreApp } from "src/hooks/useApps";
  69  import { useCapabilities } from "src/hooks/useCapabilities";
  70  import { cn, getAppDisplayName } from "src/lib/utils";
  71  
  72  function AppDetails() {
  73    const { id } = useParams() as { id: string };
  74    const { data: app, mutate: refetchApp, error } = useApp(parseInt(id));
  75    const { data: capabilities } = useCapabilities();
  76  
  77    if (error) {
  78      return <p className="text-red-500">{error.message}</p>;
  79    }
  80  
  81    if (!app || !capabilities) {
  82      return <Loading />;
  83    }
  84  
  85    return (
  86      <AppInternal
  87        app={app}
  88        refetchApp={refetchApp}
  89        capabilities={capabilities}
  90      />
  91    );
  92  }
  93  
  94  type AppInternalProps = {
  95    app: App;
  96    capabilities: WalletCapabilities;
  97    refetchApp: () => void;
  98  };
  99  
 100  function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
 101    const location = useLocation();
 102    const [isEditingPermissions, setIsEditingPermissions] = React.useState(false);
 103    const [showConfirmDialog, setShowConfirmDialog] = React.useState(false);
 104    const [showConnectionDetails, setShowConnectionDetails] =
 105      React.useState(false);
 106    const [showDisconnectAppDialog, setShowDisconnectAppDialog] =
 107      React.useState(false);
 108  
 109    const { data: albyMe } = useAlbyMe();
 110  
 111    React.useEffect(() => {
 112      const queryParams = new URLSearchParams(location.search);
 113      const editMode = queryParams.has("edit");
 114      setIsEditingPermissions(editMode);
 115    }, [location.search]);
 116  
 117    const [name, setName] = React.useState(app.name);
 118    const [permissions, setPermissions] = React.useState<AppPermissions>({
 119      scopes: app.scopes,
 120      maxAmountSat: app.maxAmountSat,
 121      budgetRenewal: app.budgetRenewal,
 122      expiresAt: app.expiresAt ? new Date(app.expiresAt) : undefined,
 123      isolated: app.isolated,
 124    });
 125    const [savedPermissions, setSavedPermissions] =
 126      React.useState<AppPermissions>(permissions);
 127  
 128    const handleSave = async () => {
 129      try {
 130        const updateAppRequest: UpdateAppRequest = {
 131          name,
 132          scopes: Array.from(permissions.scopes),
 133          budgetRenewal: permissions.budgetRenewal,
 134          expiresAt: permissions.expiresAt?.toISOString(),
 135          updateExpiresAt: true,
 136          maxAmountSat: permissions.maxAmountSat,
 137          isolated: permissions.isolated,
 138        };
 139  
 140        await request(`/api/apps/${app.appPubkey}`, {
 141          method: "PATCH",
 142          headers: {
 143            "Content-Type": "application/json",
 144          },
 145          body: JSON.stringify(updateAppRequest),
 146        });
 147  
 148        refetchApp();
 149        setIsEditingPermissions(false);
 150        setSavedPermissions(permissions);
 151        toast("Successfully updated connection");
 152      } catch (error) {
 153        handleRequestError("Failed to update connection", error);
 154      }
 155    };
 156  
 157    const handleConvertToSubwallet = async () => {
 158      try {
 159        // Only send the metadata since that's the only thing changing
 160        const updateAppRequest: UpdateAppRequest = {
 161          metadata: {
 162            ...app.metadata,
 163            app_store_app_id: SUBWALLET_APPSTORE_APP_ID,
 164          },
 165        };
 166  
 167        await request(`/api/apps/${app.appPubkey}`, {
 168          method: "PATCH",
 169          headers: {
 170            "Content-Type": "application/json",
 171          },
 172          body: JSON.stringify(updateAppRequest),
 173        });
 174  
 175        refetchApp();
 176        toast("Successfully converted to sub-wallet", {
 177          description: "This isolated app is now a sub-wallet.",
 178        });
 179      } catch (error) {
 180        handleRequestError("Failed to convert to sub-wallet", error);
 181      }
 182    };
 183  
 184    const appName = getAppDisplayName(app.name);
 185  
 186    const appStoreApp = getAppStoreApp(app);
 187    const connectedApps = useAppsForAppStoreApp(appStoreApp);
 188    const needsConfirmation =
 189      (app.isolated && !permissions.isolated) ||
 190      (!app.scopes.includes("pay_invoice") &&
 191        permissions.scopes.includes("pay_invoice"));
 192  
 193    return (
 194      <>
 195        <div className="w-full">
 196          <div className="flex flex-col gap-2">
 197            <AppHeader
 198              pageTitle={appName}
 199              title={
 200                <div className="flex flex-col sm:flex-row gap-2 sm:items-center">
 201                  <div className="flex flex-row gap-2 items-center min-w-0">
 202                    <AppAvatar app={app} className="w-10 h-10 shrink-0" />
 203                    <h2
 204                      title={appName}
 205                      className="text-xl font-semibold overflow-hidden text-ellipsis whitespace-nowrap"
 206                    >
 207                      {appName}
 208                    </h2>
 209                  </div>
 210                  <Badge
 211                    variant="positive"
 212                    className="flex items-center gap-1 self-start sm:self-center"
 213                  >
 214                    {(connectedApps?.length || 0) > 1 ? (
 215                      <DropdownMenu
 216                        modal={false}
 217                        key={JSON.stringify(app) /* force reload on app change */}
 218                      >
 219                        <DropdownMenuTrigger>
 220                          <div className="flex items-center gap-1">
 221                            {`${connectedApps?.length} Connections`}{" "}
 222                            <ChevronDownIcon className="size-3 -mr-1" />
 223                          </div>
 224                        </DropdownMenuTrigger>
 225                        <DropdownMenuContent className="w-56">
 226                          <DropdownMenuGroup>
 227                            {connectedApps?.map((connectedApp) => (
 228                              <DropdownMenuItem key={connectedApp.id}>
 229                                <Link
 230                                  to={`/apps/${connectedApp.id}`}
 231                                  className={cn(
 232                                    "flex flex-1 items-center gap-2",
 233                                    connectedApp.id === app.id && "font-semibold"
 234                                  )}
 235                                >
 236                                  {connectedApp.name}
 237                                </Link>
 238                              </DropdownMenuItem>
 239                            ))}
 240                          </DropdownMenuGroup>
 241                        </DropdownMenuContent>
 242                      </DropdownMenu>
 243                    ) : (
 244                      <>
 245                        <CheckCircleIcon className="w-3 h-3" /> Connected
 246                      </>
 247                    )}
 248                  </Badge>
 249                </div>
 250              }
 251              contentRight={
 252                <div className="flex gap-2 items-center">
 253                  {!isEditingPermissions && (
 254                    <>
 255                      <DropdownMenu modal={false}>
 256                        <Button variant="outline" size="icon" asChild>
 257                          <DropdownMenuTrigger>
 258                            <EllipsisIcon />
 259                          </DropdownMenuTrigger>
 260                        </Button>
 261                        <DropdownMenuContent align="end">
 262                          {app.isolated &&
 263                            !app.metadata?.app_store_app_id &&
 264                            albyMe?.subscription.plan_code && (
 265                              <DropdownMenuGroup>
 266                                <DropdownMenuItem
 267                                  onClick={handleConvertToSubwallet}
 268                                >
 269                                  <SquareStackIcon /> Convert to Sub-wallet
 270                                </DropdownMenuItem>
 271                              </DropdownMenuGroup>
 272                            )}
 273                          <DropdownMenuGroup>
 274                            {appStoreApp && (
 275                              <DropdownMenuItem asChild>
 276                                <Link
 277                                  to={`/apps/new?app=${appStoreApp.id}`}
 278                                  className="flex flex-1 items-center gap-2"
 279                                >
 280                                  <PlusIcon className="size-4" /> Add Another
 281                                  Connection
 282                                </Link>
 283                              </DropdownMenuItem>
 284                            )}
 285                            <DropdownMenuItem asChild>
 286                              <div
 287                                className="flex items-center gap-2"
 288                                onClick={() => setShowConnectionDetails(true)}
 289                              >
 290                                <InfoIcon className="size-4" /> Connection Details
 291                              </div>
 292                            </DropdownMenuItem>
 293                            <DropdownMenuSeparator />
 294                            <DropdownMenuItem variant="destructive" asChild>
 295                              <div
 296                                className="flex items-center gap-2"
 297                                onClick={() => setShowDisconnectAppDialog(true)}
 298                              >
 299                                <UnplugIcon className="size-4" /> Disconnect{" "}
 300                                {appName}
 301                              </div>
 302                            </DropdownMenuItem>
 303                          </DropdownMenuGroup>
 304                        </DropdownMenuContent>
 305                      </DropdownMenu>
 306                      <ResponsiveButton
 307                        variant="secondary"
 308                        onClick={() => setIsEditingPermissions(true)}
 309                        icon={SquarePenIcon}
 310                        text="Edit Connection"
 311                      />
 312                    </>
 313                  )}
 314                  {isEditingPermissions && (
 315                    <>
 316                      {isEditingPermissions && (
 317                        <div className="flex justify-center items-center gap-2">
 318                          <Button
 319                            type="button"
 320                            variant="outline"
 321                            onClick={() => {
 322                              setIsEditingPermissions(false);
 323                            }}
 324                          >
 325                            Cancel
 326                          </Button>
 327  
 328                          {needsConfirmation ? (
 329                            <AlertDialog
 330                              open={showConfirmDialog}
 331                              onOpenChange={setShowConfirmDialog}
 332                            >
 333                              <Button type="submit" form="app-details">
 334                                Save
 335                              </Button>
 336                              <AlertDialogContent>
 337                                <AlertDialogTitle>
 338                                  Confirm Update App
 339                                </AlertDialogTitle>
 340                                <AlertDialogDescription>
 341                                  <div className="space-y-2">
 342                                    {app.isolated && !permissions.isolated ? (
 343                                      <p>
 344                                        Are you sure you wish to remove the{" "}
 345                                        <span className="font-bold">
 346                                          isolated
 347                                        </span>{" "}
 348                                        status from this connection?
 349                                      </p>
 350                                    ) : (
 351                                      <p>
 352                                        Are you sure you wish to give this
 353                                        connection{" "}
 354                                        <span className="font-bold">
 355                                          pay permissions
 356                                        </span>
 357                                        ?
 358                                      </p>
 359                                    )}
 360                                    <p className="text-amber-600 dark:text-amber-400 font-medium">
 361                                      ⚠️ Warning: This applies to all apps that
 362                                      have this connection secret. Only change
 363                                      this if you know it is safe to do so,
 364                                      otherwise you could potentially lose all
 365                                      funds
 366                                      {!!permissions.maxAmountSat &&
 367                                        " up to the specified budget"}
 368                                      {permissions.isolated &&
 369                                        " that are deposited into this isolated app"}
 370                                      .
 371                                    </p>
 372                                  </div>
 373                                </AlertDialogDescription>
 374                                <AlertDialogFooter className="mt-5">
 375                                  <AlertDialogCancel>Cancel</AlertDialogCancel>
 376                                  <Button onClick={handleSave}>Save</Button>
 377                                </AlertDialogFooter>
 378                              </AlertDialogContent>
 379                            </AlertDialog>
 380                          ) : (
 381                            <Button type="submit" form="app-details">
 382                              Save
 383                            </Button>
 384                          )}
 385                        </div>
 386                      )}
 387                    </>
 388                  )}
 389                </div>
 390              }
 391              description={""}
 392            />
 393            {!isEditingPermissions && (
 394              <>
 395                {appStoreApp && (
 396                  <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
 397                    <AboutAppCard appStoreApp={appStoreApp} />
 398                    <AppLinksCard appStoreApp={appStoreApp} />
 399                  </div>
 400                )}
 401                <AppUsage app={app} />
 402              </>
 403            )}
 404            {isEditingPermissions ? (
 405              <form
 406                id="app-details"
 407                className="flex flex-col gap-2"
 408                onSubmit={(e) => {
 409                  e.preventDefault();
 410                  if (needsConfirmation) {
 411                    setShowConfirmDialog(true);
 412                    return;
 413                  }
 414                  handleSave();
 415                }}
 416              >
 417                {app.name !== ALBY_ACCOUNT_APP_NAME && (
 418                  <Card>
 419                    <CardHeader>
 420                      <CardTitle>App Name</CardTitle>
 421                    </CardHeader>
 422                    <CardContent>
 423                      <div className="flex flex-row gap-2 items-center">
 424                        <Input
 425                          autoFocus
 426                          type="text"
 427                          name="name"
 428                          value={name}
 429                          id="name"
 430                          onChange={(e) => setName(e.target.value)}
 431                          required
 432                          autoComplete="off"
 433                        />
 434                      </div>
 435                    </CardContent>
 436                  </Card>
 437                )}
 438                <Card>
 439                  <CardHeader>
 440                    <CardTitle>
 441                      <div className="flex flex-row justify-between items-center">
 442                        Permissions
 443                      </div>
 444                    </CardTitle>
 445                  </CardHeader>
 446                  <CardContent>
 447                    <Permissions
 448                      capabilities={capabilities}
 449                      permissions={permissions}
 450                      setPermissions={setPermissions}
 451                    />
 452                  </CardContent>
 453                </Card>
 454              </form>
 455            ) : (
 456              <Card>
 457                <CardHeader>
 458                  <CardTitle>
 459                    <div className="flex flex-row justify-between items-center">
 460                      Permissions
 461                    </div>
 462                  </CardTitle>
 463                </CardHeader>
 464                <CardContent>
 465                  <Permissions
 466                    capabilities={capabilities}
 467                    permissions={savedPermissions}
 468                    setPermissions={setPermissions}
 469                    readOnly
 470                  />
 471                </CardContent>
 472              </Card>
 473            )}
 474            {!isEditingPermissions && (
 475              <>
 476                {showConnectionDetails && (
 477                  <ConnectionDetailsModal
 478                    app={app}
 479                    onClose={() => setShowConnectionDetails(false)}
 480                  />
 481                )}
 482                {showDisconnectAppDialog && (
 483                  <DisconnectApp
 484                    app={app}
 485                    onClose={() => setShowDisconnectAppDialog(false)}
 486                  />
 487                )}
 488                <AppTransactionList appId={app.id} />
 489              </>
 490            )}
 491          </div>
 492        </div>
 493      </>
 494    );
 495  }
 496  
 497  export default AppDetails;
 498