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