NewApp.tsx raw

   1  import { useState } from "react";
   2  import { Navigate, useLocation, useNavigate } from "react-router";
   3  
   4  import React from "react";
   5  import { toast } from "sonner";
   6  import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
   7  import Loading from "src/components/Loading";
   8  import { appStoreApps } from "src/components/connections/SuggestedAppData";
   9  import PasswordInput from "src/components/password/PasswordInput";
  10  import {
  11    AlertDialog,
  12    AlertDialogCancel,
  13    AlertDialogContent,
  14    AlertDialogDescription,
  15    AlertDialogFooter,
  16    AlertDialogHeader,
  17    AlertDialogTitle,
  18  } from "src/components/ui/alert-dialog";
  19  import { Button } from "src/components/ui/button";
  20  import { Card, CardContent } from "src/components/ui/card";
  21  import { LinkButton } from "src/components/ui/custom/link-button";
  22  import { LoadingButton } from "src/components/ui/custom/loading-button";
  23  import { Label } from "src/components/ui/label";
  24  import { useCapabilities } from "src/hooks/useCapabilities";
  25  import { createApp } from "src/requests/createApp";
  26  import {
  27    App,
  28    AppPermissions,
  29    BudgetRenewalType,
  30    CreateAppRequest,
  31    CreateAppResponse,
  32    Nip47NotificationType,
  33    Nip47RequestMethod,
  34    READ_ONLY_SCOPES,
  35    Scope,
  36    WalletCapabilities,
  37    validBudgetRenewals,
  38  } from "src/types";
  39  
  40  import AppHeader from "src/components/AppHeader";
  41  import { IsolatedAppTopupDialog } from "src/components/IsolatedAppTopupDialog";
  42  import { InstallApp } from "src/components/connections/InstallApp";
  43  import { defineStepper } from "src/components/stepper";
  44  import { Checkbox } from "src/components/ui/checkbox";
  45  import { Input } from "src/components/ui/input";
  46  import {
  47    DEFAULT_APP_BUDGET_RENEWAL,
  48    DEFAULT_APP_BUDGET_SATS,
  49  } from "src/constants";
  50  import { useApp } from "src/hooks/useApp";
  51  import { ConnectAppCard } from "src/screens/apps/ConnectAppCard";
  52  import { handleRequestError } from "src/utils/handleRequestError";
  53  import { safeReturnToUrl } from "src/utils/safeReturnToUrl";
  54  import Permissions from "../../components/Permissions";
  55  import { AppStoreApp } from "../../components/connections/SuggestedAppData";
  56  
  57  const NewApp = () => {
  58    const { data: capabilities } = useCapabilities();
  59    if (!capabilities) {
  60      return <Loading />;
  61    }
  62  
  63    return <NewAppInternal capabilities={capabilities} />;
  64  };
  65  
  66  type NewAppInternalProps = {
  67    capabilities: WalletCapabilities;
  68  };
  69  
  70  const NewAppInternal = ({ capabilities }: NewAppInternalProps) => {
  71    const location = useLocation();
  72  
  73    const [unsupportedError, setUnsupportedError] = useState<string>();
  74    const [isLoading, setLoading] = React.useState(false);
  75  
  76    const [createAppResponse, setCreateAppResponse] =
  77      React.useState<CreateAppResponse>();
  78  
  79    const queryParams = new URLSearchParams(location.search);
  80  
  81    const appId = queryParams.get("app") ?? "";
  82    const appStoreApp = appStoreApps.find((app) => app.id === appId);
  83    const isInstallable =
  84      appStoreApp?.appleLink ||
  85      appStoreApp?.playLink ||
  86      appStoreApp?.zapStoreLink ||
  87      appStoreApp?.chromeLink ||
  88      appStoreApp?.firefoxLink;
  89  
  90    const pubkey = queryParams.get("pubkey") ?? "";
  91    const returnTo = safeReturnToUrl(queryParams.get("return_to")) ?? "";
  92  
  93    const nameParam = queryParams.get("name") || queryParams.get("c");
  94    const [appName, setAppName] = useState(nameParam || appStoreApp?.title || "");
  95  
  96    const budgetRenewalParam = queryParams.get(
  97      "budget_renewal"
  98    ) as BudgetRenewalType;
  99    const budgetMaxAmountMsatParam = queryParams.get("max_amount") ?? "";
 100    const isolatedParam = queryParams.get("isolated") ?? "";
 101    const expiresAtParam = queryParams.get("expires_at") ?? "";
 102  
 103    const reqMethodsParam = queryParams.get("request_methods") ?? "";
 104    const notificationTypesParam = queryParams.get("notification_types") ?? "";
 105  
 106    /* eslint-disable react-hooks/preserve-manual-memoization */
 107    const initialScopes: Scope[] = React.useMemo(() => {
 108      // Receive-only app store apps (e.g. merchant payment receivers) default to
 109      // read-only permissions, unless the deep link explicitly requests methods.
 110      if (appStoreApp?.readonly && !reqMethodsParam) {
 111        return capabilities.scopes.filter((scope) =>
 112          READ_ONLY_SCOPES.includes(scope)
 113        );
 114      }
 115  
 116      const methods = reqMethodsParam
 117        ? reqMethodsParam.split(" ")
 118        : capabilities.methods;
 119  
 120      const requestMethodsSet = new Set<Nip47RequestMethod>(
 121        methods as Nip47RequestMethod[]
 122      );
 123      const unsupportedMethods = Array.from(requestMethodsSet).filter(
 124        (method) => capabilities.methods.indexOf(method) < 0
 125      );
 126      if (unsupportedMethods.length) {
 127        // eslint-disable-next-line react-hooks/set-state-in-render
 128        setUnsupportedError(
 129          "This app requests methods not supported by your wallet: " +
 130            unsupportedMethods
 131        );
 132      }
 133  
 134      const notificationTypes = notificationTypesParam
 135        ? notificationTypesParam.split(" ")
 136        : reqMethodsParam
 137          ? [] // do not set notifications if only request methods provided
 138          : capabilities.notificationTypes;
 139  
 140      const notificationTypesSet = new Set<Nip47NotificationType>(
 141        notificationTypes as Nip47NotificationType[]
 142      );
 143      const unsupportedNotificationTypes = Array.from(
 144        notificationTypesSet
 145      ).filter(
 146        (notificationType) =>
 147          capabilities.notificationTypes.indexOf(notificationType) < 0
 148      );
 149      if (unsupportedNotificationTypes.length) {
 150        // eslint-disable-next-line react-hooks/set-state-in-render
 151        setUnsupportedError(
 152          "This app requests notification types not supported by your wallet: " +
 153            unsupportedNotificationTypes
 154        );
 155      }
 156  
 157      const scopes: Scope[] = [];
 158      if (
 159        requestMethodsSet.has("pay_invoice") ||
 160        requestMethodsSet.has("pay_keysend") ||
 161        requestMethodsSet.has("multi_pay_invoice") ||
 162        requestMethodsSet.has("multi_pay_keysend")
 163      ) {
 164        scopes.push("pay_invoice");
 165      }
 166  
 167      if (requestMethodsSet.has("get_info")) {
 168        scopes.push("get_info");
 169      }
 170      if (requestMethodsSet.has("get_balance")) {
 171        scopes.push("get_balance");
 172      }
 173      if (
 174        requestMethodsSet.has("make_invoice") ||
 175        requestMethodsSet.has("make_hold_invoice") ||
 176        requestMethodsSet.has("settle_hold_invoice") ||
 177        requestMethodsSet.has("cancel_hold_invoice")
 178      ) {
 179        scopes.push("make_invoice");
 180      }
 181      if (requestMethodsSet.has("lookup_invoice")) {
 182        scopes.push("lookup_invoice");
 183      }
 184      if (requestMethodsSet.has("list_transactions")) {
 185        scopes.push("list_transactions");
 186      }
 187      if (requestMethodsSet.has("sign_message") && isolatedParam !== "true") {
 188        scopes.push("sign_message");
 189      }
 190      if (notificationTypes.length) {
 191        scopes.push("notifications");
 192      }
 193  
 194      return scopes;
 195    }, [
 196      appStoreApp?.readonly,
 197      capabilities.methods,
 198      capabilities.notificationTypes,
 199      capabilities.scopes,
 200      isolatedParam,
 201      notificationTypesParam,
 202      reqMethodsParam,
 203    ]);
 204    /* eslint-enable react-hooks/preserve-manual-memoization */
 205  
 206    const parseExpiresParam = (expiresParam: string): Date | undefined => {
 207      const expiresParamTimestamp = parseInt(expiresParam);
 208      if (!isNaN(expiresParamTimestamp)) {
 209        const expiry = new Date(expiresParamTimestamp * 1000);
 210        expiry.setHours(23, 59, 59);
 211        return expiry;
 212      }
 213      return undefined;
 214    };
 215  
 216    const [superuser, setSuperuser] = useState(appStoreApp?.superuser || false);
 217    const [
 218      showSuperuserConfirmPasswordDialog,
 219      setShowSuperuserConfirmPasswordDialog,
 220    ] = useState(false);
 221    const [unlockPassword, setUnlockPassword] = useState("");
 222  
 223    const [permissions, setPermissions] = useState<AppPermissions>({
 224      scopes: initialScopes,
 225      maxAmountSat: budgetMaxAmountMsatParam
 226        ? Math.floor(parseInt(budgetMaxAmountMsatParam) / 1000)
 227        : DEFAULT_APP_BUDGET_SATS,
 228      budgetRenewal: validBudgetRenewals.includes(budgetRenewalParam)
 229        ? budgetRenewalParam
 230        : budgetMaxAmountMsatParam
 231          ? "never"
 232          : DEFAULT_APP_BUDGET_RENEWAL,
 233      expiresAt: parseExpiresParam(expiresAtParam),
 234      isolated: isolatedParam === "true",
 235    });
 236  
 237    const { Stepper } = React.useMemo(
 238      () =>
 239        defineStepper(
 240          ...(appStoreApp
 241            ? [
 242                {
 243                  id: "install",
 244                  title: "",
 245                },
 246              ]
 247            : []),
 248          {
 249            id: "configure",
 250            title: "Configure",
 251          },
 252          ...(returnTo ? [] : [{ id: "finalize", title: "Finalize" }])
 253        ),
 254      [appStoreApp, returnTo]
 255    );
 256  
 257    const handleCreateApp = async (nextFunc: () => void) => {
 258      if (!permissions.scopes.length) {
 259        toast("Please specify wallet permissions.");
 260        return;
 261      }
 262  
 263      setLoading(true);
 264      try {
 265        const createAppRequest: CreateAppRequest = {
 266          name: appName,
 267          pubkey,
 268          budgetRenewal: permissions.budgetRenewal,
 269          maxAmountSat: permissions.maxAmountSat || 0,
 270          scopes: [
 271            ...permissions.scopes,
 272            ...(superuser ? ["superuser" satisfies Scope] : []),
 273          ] as Scope[],
 274          expiresAt: permissions.expiresAt?.toISOString(),
 275          returnTo: returnTo,
 276          isolated: permissions.isolated,
 277          metadata: {
 278            app_store_app_id: appStoreApp?.id,
 279          },
 280          unlockPassword,
 281        };
 282  
 283        const createAppResponse = await createApp(createAppRequest);
 284  
 285        // dispatch a success event which can be listened to by the opener or by the app that embedded the webview
 286        // this gives those apps the chance to know the user has enabled the connection
 287        const nwcEvent = new CustomEvent("nwc:success", {
 288          detail: {
 289            relayUrl: createAppResponse.relayUrls[0], // TODO: deprecate
 290            relayUrls: createAppResponse.relayUrls, // TODO: add to spec
 291            walletPubkey: createAppResponse.walletPubkey,
 292            lud16: createAppResponse.lud16,
 293          },
 294        });
 295        window.dispatchEvent(nwcEvent);
 296  
 297        // notify the opener of the successful connection
 298        if (window.opener) {
 299          window.opener.postMessage(
 300            {
 301              type: "nwc:success",
 302              relayUrl: createAppResponse.relayUrls[0], // TODO: deprecate
 303              relayUrls: createAppResponse.relayUrls, // TODO: add to spec
 304              walletPubkey: createAppResponse.walletPubkey,
 305              lud16: createAppResponse.lud16,
 306            },
 307            "*"
 308          );
 309        }
 310  
 311        const returnToUrl = safeReturnToUrl(createAppResponse.returnTo);
 312        if (returnToUrl) {
 313          // open connection URI directly in an app
 314          // eslint-disable-next-line react-hooks/immutability
 315          window.location.href = returnToUrl;
 316          return;
 317        }
 318        toast("App created");
 319        setCreateAppResponse(createAppResponse);
 320  
 321        nextFunc();
 322      } catch (error) {
 323        handleRequestError("Failed to create app", error);
 324      }
 325      setLoading(false);
 326    };
 327  
 328    if (unsupportedError) {
 329      return (
 330        <>
 331          <AppHeader
 332            pageTitle="Unsupported App"
 333            title="Unsupported App"
 334            description={unsupportedError}
 335          />
 336          <p>Try the Alby Hub LDK backend for extra features.</p>
 337        </>
 338      );
 339    }
 340  
 341    return (
 342      <>
 343        <AppHeader
 344          pageTitle={appName ? `Connect to ${appName}` : "Connect a new app"}
 345          title={appName ? `Connect to ${appName}` : "Connect a new app"}
 346          icon={
 347            appStoreApp?.logo ? (
 348              <img
 349                src={appStoreApp.logo}
 350                alt="logo"
 351                className="inline rounded-lg w-14 h-14"
 352              />
 353            ) : undefined
 354          }
 355          description="Configure wallet permissions for the app and follow instructions to finalize the connection"
 356        />
 357  
 358        <Stepper.Provider className="space-y-4 max-w-lg" variant="vertical">
 359          {({ methods }) => (
 360            <>
 361              <Stepper.Navigation>
 362                {methods.state.all.map((step) => (
 363                  <Stepper.Step
 364                    key={step.id}
 365                    of={step.id}
 366                    onClick={() =>
 367                      methods.state.current.data.id === "configure" &&
 368                      step.id === "install"
 369                        ? methods.navigation.goTo(step.id)
 370                        : undefined
 371                    }
 372                  >
 373                    <Stepper.Title>
 374                      {step.title ||
 375                        (isInstallable ? "Install" : "Open") + " " + appName}
 376                    </Stepper.Title>
 377                    {methods.flow.when(step.id, () => (
 378                      <>
 379                        {methods.flow.switch({
 380                          install: () =>
 381                            appStoreApp && (
 382                              <InstallApp appStoreApp={appStoreApp} />
 383                            ),
 384                          configure: () => (
 385                            <form
 386                              id="new-app"
 387                              className="flex flex-col gap-4"
 388                              onSubmit={(e: React.FormEvent) => {
 389                                e.preventDefault();
 390                                if (superuser) {
 391                                  setShowSuperuserConfirmPasswordDialog(true);
 392                                  return;
 393                                }
 394                                handleCreateApp(() => methods.navigation.next());
 395                              }}
 396                            >
 397                              <SuperuserConfirmPasswordDialog
 398                                open={showSuperuserConfirmPasswordDialog}
 399                                setOpen={setShowSuperuserConfirmPasswordDialog}
 400                                onSubmit={() => {
 401                                  handleCreateApp(() =>
 402                                    methods.navigation.next()
 403                                  );
 404                                }}
 405                                unlockPassword={unlockPassword}
 406                                setUnlockPassword={setUnlockPassword}
 407                              />
 408                              {!appStoreApp && (
 409                                <div className="w-full grid gap-1.5">
 410                                  <Label htmlFor="name">Name</Label>
 411                                  <Input
 412                                    autoFocus
 413                                    type="text"
 414                                    name="name"
 415                                    value={appName}
 416                                    id="name"
 417                                    onChange={(e) => setAppName(e.target.value)}
 418                                    required
 419                                    autoComplete="off"
 420                                  />
 421                                  <p className="text-xs text-muted-foreground">
 422                                    Name of the app or purpose of the connection
 423                                  </p>
 424                                </div>
 425                              )}
 426                              <div className="flex flex-col gap-2 w-full">
 427                                <Permissions
 428                                  capabilities={capabilities}
 429                                  permissions={permissions}
 430                                  setPermissions={setPermissions}
 431                                />
 432                              </div>
 433                              {appStoreApp?.superuser && (
 434                                <div className="flex mt-2">
 435                                  <Checkbox
 436                                    id="superuser"
 437                                    checked={superuser}
 438                                    onCheckedChange={() =>
 439                                      setSuperuser(!superuser)
 440                                    }
 441                                    className="mt-0.5"
 442                                  />
 443                                  <Label
 444                                    htmlFor="superuser"
 445                                    className="ml-2 flex flex-col items-start justify-center cursor-pointer"
 446                                  >
 447                                    <div>
 448                                      Enable accepting connections to other apps
 449                                    </div>
 450                                    <div className="text-muted-foreground font-normal">
 451                                      Allow this app to let you authorize new
 452                                      connections to your Alby Hub.
 453                                    </div>
 454                                  </Label>
 455                                </div>
 456                              )}
 457  
 458                              {returnTo && (
 459                                <p className="text-xs text-muted-foreground">
 460                                  You will automatically return to {returnTo}
 461                                </p>
 462                              )}
 463                            </form>
 464                          ),
 465                          finalize: () =>
 466                            createAppResponse && (
 467                              <div className="pl-8 max-w-md">
 468                                <FinalizeConnection
 469                                  createAppResponse={createAppResponse}
 470                                  appStoreApp={appStoreApp}
 471                                />
 472                              </div>
 473                            ),
 474                        })}
 475                        {(!methods.state.isLast || returnTo) && (
 476                          <Stepper.Controls className="mt-6">
 477                            {!methods.state.isFirst && (
 478                              <Button
 479                                type="button"
 480                                variant="secondary"
 481                                onClick={() => methods.navigation.prev()}
 482                              >
 483                                Back
 484                              </Button>
 485                            )}
 486                            <LoadingButton
 487                              loading={isLoading}
 488                              type={step.id === "configure" ? "submit" : "button"}
 489                              form={
 490                                step.id === "configure" ? "new-app" : undefined
 491                              }
 492                              onClick={
 493                                step.id === "configure"
 494                                  ? undefined
 495                                  : () => methods.navigation.next()
 496                              }
 497                            >
 498                              {step.id === "configure" && pubkey
 499                                ? "Connect"
 500                                : "Next"}
 501                            </LoadingButton>
 502                          </Stepper.Controls>
 503                        )}
 504                      </>
 505                    ))}
 506                  </Stepper.Step>
 507                ))}
 508              </Stepper.Navigation>
 509            </>
 510          )}
 511        </Stepper.Provider>
 512      </>
 513    );
 514  };
 515  
 516  export default NewApp;
 517  
 518  function FinalizeConnection({
 519    createAppResponse,
 520    appStoreApp,
 521  }: {
 522    createAppResponse: CreateAppResponse;
 523    appStoreApp: AppStoreApp | undefined;
 524  }) {
 525    const navigate = useNavigate();
 526  
 527    const pairingUri = createAppResponse.pairingUri;
 528    const hasPairingSecret = !!createAppResponse.pairingSecretKey;
 529    const { data: app } = useApp(createAppResponse.id, true);
 530  
 531    React.useEffect(() => {
 532      if (app?.lastUsedAt) {
 533        toast("Connection established!", {
 534          description: "You can now use the app with your Alby Hub.",
 535        });
 536        navigate(`/apps/${createAppResponse.id}`);
 537      }
 538    }, [app?.lastUsedAt, createAppResponse.id, navigate]);
 539  
 540    if (!createAppResponse) {
 541      return <Navigate to="/apps/new" />;
 542    }
 543  
 544    if (!hasPairingSecret) {
 545      return <WaitingForConnection app={app} />;
 546    }
 547  
 548    return (
 549      <>
 550        <div className="flex flex-col gap-3 sensitive">
 551          {appStoreApp ? (
 552            <>{appStoreApp.finalizeGuide}</>
 553          ) : (
 554            <ol className="list-decimal list-inside">
 555              <li>Open the app you wish to connect to</li>
 556              <li>
 557                Find settings to connect your wallet (may be under{" "}
 558                <span className="font-semibold">Nostr Wallet Connect</span> or{" "}
 559                <span className="font-semibold">NWC</span>).
 560              </li>
 561              <li>Scan or paste the connection secret</li>
 562            </ol>
 563          )}
 564  
 565          {app?.isolated && (
 566            <li>
 567              Optional: Top up sub-wallet balance (
 568              <FormattedBitcoinAmount amountMsat={app.balanceMsat} />){" "}
 569              <IsolatedAppTopupDialog appId={app.id}>
 570                <Button size="sm" variant="secondary">
 571                  Top Up
 572                </Button>
 573              </IsolatedAppTopupDialog>
 574            </li>
 575          )}
 576          {app && (
 577            <ConnectAppCard
 578              app={app}
 579              pairingUri={pairingUri}
 580              appStoreApp={appStoreApp}
 581            />
 582          )}
 583        </div>
 584      </>
 585    );
 586  }
 587  
 588  function WaitingForConnection({ app }: { app: App | undefined }) {
 589    const [timeout, setTimeout] = React.useState(false);
 590  
 591    React.useEffect(() => {
 592      const timeoutId = window.setTimeout(() => {
 593        setTimeout(true);
 594      }, 30000);
 595      return () => window.clearTimeout(timeoutId);
 596    }, []);
 597  
 598    return (
 599      <Card className="w-full">
 600        <CardContent className="flex flex-col items-center gap-4 py-6">
 601          <div className="flex flex-row items-center gap-2 text-sm font-medium">
 602            <Loading className="size-4" />
 603            <p>Waiting for connection</p>
 604          </div>
 605          <p className="text-xs text-muted-foreground text-center">
 606            Make your first request to complete the connection.
 607          </p>
 608          {timeout && app && (
 609            <div className="text-xs text-muted-foreground flex flex-col gap-3 items-center text-center border-t pt-4 w-full">
 610              Connecting is taking longer than usual.
 611              <LinkButton to={`/apps/${app.id}`} variant="secondary" size="sm">
 612                Continue anyway
 613              </LinkButton>
 614            </div>
 615          )}
 616        </CardContent>
 617      </Card>
 618    );
 619  }
 620  
 621  type SuperuserConfirmPasswordDialogProps = {
 622    open: boolean;
 623    setOpen: (open: boolean) => void;
 624    onSubmit: () => void;
 625    unlockPassword: string;
 626    setUnlockPassword: (password: string) => void;
 627  };
 628  
 629  function SuperuserConfirmPasswordDialog({
 630    open,
 631    setOpen,
 632    onSubmit,
 633    unlockPassword,
 634    setUnlockPassword,
 635  }: SuperuserConfirmPasswordDialogProps) {
 636    return (
 637      <AlertDialog open={open}>
 638        <AlertDialogContent>
 639          <form
 640            onSubmit={(e: React.FormEvent) => {
 641              e.preventDefault();
 642              onSubmit();
 643            }}
 644          >
 645            <AlertDialogHeader>
 646              <AlertDialogTitle>Confirm New Connection</AlertDialogTitle>
 647              <AlertDialogDescription>
 648                <div className="flex flex-col">
 649                  <p>
 650                    Alby Go will be given permission to create other app
 651                    connections which can spend your balance.
 652                  </p>
 653  
 654                  <p className="mt-4">
 655                    Warning: Alby Go can create connections with a larger budget
 656                    than the one set for Alby Go. Make sure to always set a
 657                    budget.
 658                  </p>
 659  
 660                  <p className="mt-4">
 661                    Please enter your unlock password to continue.
 662                  </p>
 663                  <div className="grid gap-1.5 mt-4">
 664                    <Label htmlFor="password">Unlock Password</Label>
 665                    <PasswordInput
 666                      id="password"
 667                      onChange={setUnlockPassword}
 668                      autoFocus
 669                      value={unlockPassword}
 670                    />
 671                  </div>
 672                </div>
 673              </AlertDialogDescription>
 674            </AlertDialogHeader>
 675            <AlertDialogFooter className="mt-3">
 676              <AlertDialogCancel onClick={() => setOpen(false)}>
 677                Cancel
 678              </AlertDialogCancel>
 679              <Button type="submit">Confirm</Button>
 680            </AlertDialogFooter>
 681          </form>
 682        </AlertDialogContent>
 683      </AlertDialog>
 684    );
 685  }
 686