Backup.tsx raw
1 import {
2 AlertTriangleIcon,
3 ExternalLinkIcon,
4 EyeIcon,
5 Link2Icon,
6 TriangleAlertIcon,
7 } from "lucide-react";
8 import React, { useState } from "react";
9
10 import { useNavigate, useSearchParams } from "react-router";
11 import ExternalLink from "src/components/ExternalLink";
12 import Loading from "src/components/Loading";
13 import MnemonicDialog from "src/components/mnemonic/MnemonicDialog";
14 import PasswordInput from "src/components/password/PasswordInput";
15 import SettingsHeader from "src/components/SettingsHeader";
16 import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
17 import {
18 AlertDialog,
19 AlertDialogAction,
20 AlertDialogCancel,
21 AlertDialogContent,
22 AlertDialogDescription,
23 AlertDialogFooter,
24 AlertDialogHeader,
25 AlertDialogTitle,
26 AlertDialogTrigger,
27 } from "src/components/ui/alert-dialog";
28 import { Badge } from "src/components/ui/badge";
29 import { Button } from "src/components/ui/button";
30 import { Checkbox } from "src/components/ui/checkbox";
31
32 import { toast } from "sonner";
33 import { LoadingButton } from "src/components/ui/custom/loading-button";
34 import { Label } from "src/components/ui/label";
35 import { Separator } from "src/components/ui/separator";
36 import { UpgradeDialog } from "src/components/UpgradeDialog";
37 import { useAlbyMe } from "src/hooks/useAlbyMe";
38 import { useInfo } from "src/hooks/useInfo";
39 import { useMigrateLDKStorage } from "src/hooks/useMigrateLDKStorage";
40 import { InfoResponse, MnemonicResponse } from "src/types";
41 import { request } from "src/utils/request";
42
43 export default function Backup() {
44 const {
45 data: info,
46 hasMnemonic,
47 hasChannelManagement,
48 hasNodeBackup,
49 } = useInfo();
50 const { data: me } = useAlbyMe();
51 const [unlockPassword, setUnlockPassword] = useState("");
52 const [decryptedMnemonic, setDecryptedMnemonic] = useState("");
53 const [loading, setLoading] = useState(false);
54 const [isDialogOpen, setIsDialogOpen] = useState(false);
55 const navigate = useNavigate();
56
57 const onSubmitPassword = async (e: React.FormEvent) => {
58 e.preventDefault();
59 try {
60 setLoading(true);
61 const result = await request<MnemonicResponse>("/api/mnemonic", {
62 method: "POST",
63 headers: {
64 "Content-Type": "application/json",
65 },
66 body: JSON.stringify({ unlockPassword }),
67 });
68
69 setDecryptedMnemonic(result?.mnemonic ?? "");
70 setIsDialogOpen(true);
71 } catch {
72 toast.error("Incorrect password", {
73 description: "Failed to decrypt recovery phrase.",
74 });
75 } finally {
76 setLoading(false);
77 }
78 };
79
80 return (
81 <>
82 <SettingsHeader
83 pageTitle="Backup"
84 title="Backup"
85 description={
86 <>
87 <span className="text-muted-foreground">
88 Backup your recovery phrase
89 {hasChannelManagement && " and channel states"}. These backups are
90 for disaster recovery only.
91 {hasNodeBackup &&
92 " To migrate your node, please use the migration tool."}{" "}
93 </span>
94 <a
95 href="https://guides.getalby.com/user-guide/alby-hub/backups-and-recover"
96 target="_blank"
97 rel="noreferrer noopener"
98 className="text-foreground underline"
99 >
100 Learn more about backups
101 </a>
102 </>
103 }
104 />
105
106 <div className="space-y-6 pb-10">
107 {hasMnemonic && (
108 <>
109 <div className="flex flex-col gap-6">
110 <div>
111 <h3 className="text-lg font-medium">Recovery Phrase</h3>
112 <p className="text-sm text-muted-foreground">
113 Your recovery phrase is a group of 12 random words that back
114 up your wallet{" "}
115 {info?.backendType === "LDK" ? "on-chain balance" : "balance"}
116 . Using them is the only way to recover access to your wallet
117 on another machine or when you lose your unlock password.
118 </p>
119 </div>
120 <Alert variant="destructive">
121 <AlertTriangleIcon />
122 <AlertTitle>Important</AlertTitle>
123 <AlertDescription>
124 If you lose access to your Hub and do not have your recovery
125 phrase, you will lose access to your funds.
126 </AlertDescription>
127 </Alert>
128 {info?.backendType === "CASHU" && <CashuMnemonicWarning />}
129
130 <div>
131 <form
132 onSubmit={onSubmitPassword}
133 className="max-w-md flex flex-col gap-6"
134 >
135 <div className="grid gap-2">
136 <Label htmlFor="password">Password</Label>
137 <PasswordInput
138 id="password"
139 onChange={setUnlockPassword}
140 value={unlockPassword}
141 />
142 <p className="text-sm text-muted-foreground">
143 Enter your unlock password to view your recovery phrase.
144 </p>
145 </div>
146 {!!unlockPassword && (
147 <div className="flex">
148 <Checkbox id="private" required className="mt-0.5" />
149 <Label htmlFor="private" className="ml-2 cursor-pointer">
150 I'll NEVER share my recovery phrase with anyone,
151 including Alby support
152 </Label>
153 </div>
154 )}
155 <div className="flex justify-start">
156 <LoadingButton
157 loading={loading}
158 variant="secondary"
159 className="flex gap-2 justify-center"
160 >
161 <EyeIcon />
162 View Recovery Phrase
163 </LoadingButton>
164 </div>
165 </form>
166 </div>
167 <MnemonicDialog
168 open={isDialogOpen}
169 onOpenChange={setIsDialogOpen}
170 mnemonic={decryptedMnemonic}
171 />
172 </div>
173 <Separator />
174 </>
175 )}
176
177 {hasChannelManagement && (
178 <div className="flex flex-col gap-8">
179 <div>
180 <h3 className="text-lg font-medium">Channels Backup</h3>
181 <p className="text-sm text-muted-foreground">
182 Your lightning balance can only be recovered on-chain by closing
183 your lightning channels. In case of recovery of your Alby Hub a
184 request will be sent to your peers to close your existing
185 channels. To recover the funds from these channels, a channel
186 backup needs to be created every time you open a new channel.
187 </p>
188 </div>
189
190 <div>
191 <div className="flex gap-2 mb-1 items-center">
192 <h3 className="text-sm font-medium">
193 Automatic Channels Backup
194 </h3>
195 {info?.albyAccountConnected ? (
196 <Badge variant={"positive"}>Active</Badge>
197 ) : (
198 <Badge>Recommended</Badge>
199 )}
200 </div>
201 {info?.albyAccountConnected ? (
202 <>
203 <p className="text-muted-foreground text-sm mb-8">
204 Your channel state is backed up automatically after each
205 channel creation. Using an external recovery tool and your
206 recovery phrase, you can recover your funds from channels to
207 your on-chain balance as long as your channel partners are
208 online.
209 </p>
210 {info?.vssSupported && (
211 <>
212 <div className="flex gap-2 mb-1 items-center">
213 <h3 className="text-sm font-medium">
214 Dynamic Channels Backup With Instant Recovery
215 </h3>
216 {me?.subscription.plan_code && info.ldkVssEnabled ? (
217 <Badge variant={"positive"}>Active</Badge>
218 ) : (
219 <Badge className="shrink-0">Alby Cloud</Badge>
220 )}
221 </div>
222 <p className="text-sm text-muted-foreground mb-4">
223 When enabled, your channels state is dynamically updated
224 and stored end-to-end encrypted by Alby's Versioned
225 Storage Service. This allows you to recover your
226 lightning balance with your recovery phrase alone,
227 without having to close your channels.
228 </p>
229
230 {!info.ldkVssEnabled &&
231 (!me?.subscription.plan_code ? (
232 <UpgradeDialog>
233 <Button variant="secondary" size={"lg"}>
234 Upgrade to Enable Dynamic Channels Backup
235 </Button>
236 </UpgradeDialog>
237 ) : (
238 <DynamicChannelsBackupDialog info={info} />
239 ))}
240 </>
241 )}
242 </>
243 ) : (
244 <div className="flex flex-col gap-8">
245 <div>
246 <p className="text-muted-foreground text-sm mb-4">
247 Link your Alby Account to enable automatic channel backups
248 after each channel creation.
249 </p>
250 <Button
251 type="button"
252 variant={"secondary"}
253 className="flex gap-2 justify-center"
254 onClick={() => navigate("/alby/account")}
255 >
256 <Link2Icon />
257 Link Alby Account to Enable
258 </Button>
259 </div>
260 <div className="flex flex-col gap-1">
261 <div className="flex gap-2 items-center">
262 <h3 className="text-sm font-medium">
263 Manual Channels Backup
264 </h3>
265 <Badge variant={"positive"}>Active</Badge>
266 </div>
267 <p>
268 <span className="text-muted-foreground text-sm">
269 To backup your channels state manually, without Alby
270 Account linked, follow the
271 </span>{" "}
272 <ExternalLink
273 to="https://guides.getalby.com/user-guide/alby-hub/backups-and-recover#alby-hub-self-hosted-without-an-alby-account"
274 className="underline inline-flex items-center text-sm"
275 >
276 manual backups guide
277 <ExternalLinkIcon className="size-4 ml-1" />
278 </ExternalLink>
279 </p>
280 </div>
281 </div>
282 )}
283 </div>
284 </div>
285 )}
286
287 {!hasMnemonic && !hasChannelManagement && !info?.vssSupported && (
288 <p className="text-sm text-muted-foreground">
289 No recovery phrase or channel state backup present.
290 </p>
291 )}
292 </div>
293 </>
294 );
295 }
296
297 type Props = {
298 info: InfoResponse;
299 };
300
301 function DynamicChannelsBackupDialog({ info }: Props) {
302 const [open, setOpen] = useState(false);
303 const [searchParams] = useSearchParams();
304
305 React.useEffect(() => {
306 if (searchParams.get("dynamic") === "true") {
307 setOpen(true);
308 }
309 }, [searchParams]);
310
311 const { isMigratingStorage, migrateLDKStorage } = useMigrateLDKStorage();
312
313 return (
314 <AlertDialog open={open} onOpenChange={setOpen}>
315 <AlertDialogTrigger asChild>
316 <LoadingButton
317 variant="secondary"
318 loading={isMigratingStorage}
319 disabled={info.ldkVssEnabled}
320 size={"lg"}
321 >
322 Enable Dynamic Channels Backup
323 </LoadingButton>
324 </AlertDialogTrigger>
325 <AlertDialogContent>
326 <AlertDialogHeader>
327 <AlertDialogTitle>Alby Hub Restart Required</AlertDialogTitle>
328 <AlertDialogDescription>
329 <div>
330 <p>
331 By enabling dynamic channel backups, your channels state is
332 dynamically updated and stored end-to-end encrypted by Alby's
333 Versioned Storage Service. This allows you to recover your
334 lightning balance with your recovery phrase alone, without
335 having to close your channels.
336 </p>
337 <p className="mt-2">
338 As part of enabling dynamic channels backup your hub will be
339 shut down, and you will need to enter your unlock password to
340 start it again.
341 </p>
342 <p className="mt-2">
343 Please ensure you have no pending payments or channel closures
344 before continuing.
345 </p>
346 </div>
347 </AlertDialogDescription>
348 </AlertDialogHeader>
349 <AlertDialogFooter>
350 <AlertDialogCancel>Cancel</AlertDialogCancel>
351 <AlertDialogAction onClick={() => migrateLDKStorage("VSS")}>
352 Confirm
353 </AlertDialogAction>
354 </AlertDialogFooter>
355 </AlertDialogContent>
356 </AlertDialog>
357 );
358 }
359
360 function CashuMnemonicWarning() {
361 const [mnemonicMatches, setMnemonicMatches] = React.useState<boolean>();
362
363 React.useEffect(() => {
364 (async () => {
365 try {
366 const result: { matches: boolean } | undefined = await request(
367 "/api/command",
368 {
369 method: "POST",
370 body: JSON.stringify({ command: "checkmnemonic" }),
371 headers: {
372 "Content-Type": "application/json",
373 },
374 }
375 );
376 setMnemonicMatches(result?.matches);
377 } catch (error) {
378 console.error(error);
379 }
380 })();
381 }, []);
382
383 if (mnemonicMatches === undefined) {
384 return <Loading />;
385 }
386
387 if (mnemonicMatches) {
388 return null;
389 }
390
391 return (
392 <Alert variant="warning">
393 <TriangleAlertIcon />
394 <AlertTitle>
395 Your Cashu wallet uses a different recovery phrase
396 </AlertTitle>
397 <AlertDescription>
398 <p>
399 Please send your funds to a different wallet, then go to settings{" "}
400 {"->"} debug tools {"->"} execute node command {"->"}{" "}
401 <span className="font-mono">reset</span>. You will then receive a
402 fresh cashu wallet with the correct recovery phrase.
403 </p>
404 </AlertDescription>
405 </Alert>
406 );
407 }
408