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