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