feat: added backup and restore
a9a64c8f
2 file(s) · +221 −72
| 1 | - | import { BadgeCheck, Bell, CreditCard, LogOut, Sparkles } from "lucide-react"; |
|
| 1 | + | import { |
|
| 2 | + | BadgeCheck, |
|
| 3 | + | Bell, |
|
| 4 | + | BookKey, |
|
| 5 | + | Copy, |
|
| 6 | + | CreditCard, |
|
| 7 | + | Eye, |
|
| 8 | + | EyeOff, |
|
| 9 | + | LogOut, |
|
| 10 | + | Sparkles, |
|
| 11 | + | Upload, |
|
| 12 | + | } from "lucide-react"; |
|
| 13 | + | import { |
|
| 14 | + | Dialog, |
|
| 15 | + | DialogContent, |
|
| 16 | + | DialogDescription, |
|
| 17 | + | DialogHeader, |
|
| 18 | + | DialogTitle, |
|
| 19 | + | DialogTrigger, |
|
| 20 | + | } from "@/components/ui/dialog"; |
|
| 21 | + | import { Button } from "@/components/ui/button"; |
|
| 22 | + | import { use, useState } from "react"; |
|
| 23 | + | import { useEvolu } from "@/lib/evolu"; |
|
| 2 | 24 | ||
| 3 | 25 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; |
|
| 4 | 26 | import { |
|
| 16 | 38 | SidebarMenuItem, |
|
| 17 | 39 | useSidebar, |
|
| 18 | 40 | } from "@/components/ui/sidebar"; |
|
| 41 | + | import { Mnemonic } from "@evolu/common"; |
|
| 19 | 42 | ||
| 20 | - | export function NavUser({ |
|
| 21 | - | user, |
|
| 22 | - | }: { |
|
| 23 | - | user: { |
|
| 24 | - | name: string; |
|
| 25 | - | email: string; |
|
| 26 | - | avatar: string; |
|
| 27 | - | }; |
|
| 28 | - | }) { |
|
| 43 | + | export function NavUser() { |
|
| 29 | 44 | const { isMobile } = useSidebar(); |
|
| 45 | + | const [isDialogOpen, setIsDialogOpen] = useState(false); |
|
| 46 | + | const [isRestoreDialogOpen, setIsRestoreDialogOpen] = useState(false); |
|
| 47 | + | const [backupPhrase, setBackupPhrase] = useState<Mnemonic | null>(); |
|
| 48 | + | const [isRevealed, setIsRevealed] = useState(false); |
|
| 49 | + | const [isCopied, setIsCopied] = useState(false); |
|
| 50 | + | const [restoreMnemonic, setRestoreMnemonic] = useState(""); |
|
| 51 | + | ||
| 52 | + | function maskPhrase(phrase: string | null | undefined) { |
|
| 53 | + | if (!phrase) return ""; |
|
| 54 | + | const words = phrase |
|
| 55 | + | .trim() |
|
| 56 | + | .split(/\s+/) |
|
| 57 | + | .filter((word) => word.length > 0); |
|
| 58 | + | return words.map((word) => "•".repeat(word.length)).join(" "); |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | const evolu = useEvolu(); |
|
| 62 | + | const owner = use(evolu.appOwner); |
|
| 63 | + | ||
| 64 | + | function backup() { |
|
| 65 | + | setBackupPhrase(owner.mnemonic); |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | function copyToClipboard() { |
|
| 69 | + | if (backupPhrase) { |
|
| 70 | + | navigator.clipboard.writeText(backupPhrase); |
|
| 71 | + | setIsCopied(true); |
|
| 72 | + | setTimeout(() => setIsCopied(false), 2000); |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | function handleDialogOpenChange(open: boolean) { |
|
| 77 | + | setIsDialogOpen(open); |
|
| 78 | + | if (open) { |
|
| 79 | + | backup(); |
|
| 80 | + | } else { |
|
| 81 | + | // Reset state when dialog closes |
|
| 82 | + | setIsRevealed(false); |
|
| 83 | + | setIsCopied(false); |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | function handleRestoreDialogOpenChange(open: boolean) { |
|
| 88 | + | setIsRestoreDialogOpen(open); |
|
| 89 | + | if (!open) { |
|
| 90 | + | setRestoreMnemonic(""); |
|
| 91 | + | } |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | function handleRestore() { |
|
| 95 | + | if (restoreMnemonic.trim()) { |
|
| 96 | + | evolu.restoreAppOwner(restoreMnemonic as Mnemonic); |
|
| 97 | + | setIsRestoreDialogOpen(false); |
|
| 98 | + | setRestoreMnemonic(""); |
|
| 99 | + | } |
|
| 100 | + | } |
|
| 30 | 101 | ||
| 31 | 102 | return ( |
|
| 32 | - | <SidebarMenu> |
|
| 33 | - | <SidebarMenuItem> |
|
| 34 | - | <DropdownMenu> |
|
| 35 | - | <DropdownMenuTrigger asChild> |
|
| 36 | - | <SidebarMenuButton |
|
| 37 | - | size="lg" |
|
| 38 | - | className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground md:h-8 md:p-0" |
|
| 103 | + | <> |
|
| 104 | + | <Dialog open={isDialogOpen} onOpenChange={handleDialogOpenChange}> |
|
| 105 | + | <SidebarMenu> |
|
| 106 | + | <SidebarMenuItem> |
|
| 107 | + | <DropdownMenu> |
|
| 108 | + | <DropdownMenuTrigger asChild> |
|
| 109 | + | <SidebarMenuButton |
|
| 110 | + | size="lg" |
|
| 111 | + | className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground md:h-8 md:p-0" |
|
| 112 | + | > |
|
| 113 | + | <div className="grid flex-1 text-center text-sm leading-tight"> |
|
| 114 | + | <span className="truncate font-medium">Settings</span> |
|
| 115 | + | </div> |
|
| 116 | + | </SidebarMenuButton> |
|
| 117 | + | </DropdownMenuTrigger> |
|
| 118 | + | <DropdownMenuContent |
|
| 119 | + | className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" |
|
| 120 | + | side={isMobile ? "bottom" : "right"} |
|
| 121 | + | align="end" |
|
| 122 | + | sideOffset={4} |
|
| 123 | + | > |
|
| 124 | + | <DropdownMenuGroup> |
|
| 125 | + | <DialogTrigger asChild> |
|
| 126 | + | <DropdownMenuItem> |
|
| 127 | + | <BookKey /> |
|
| 128 | + | Backup |
|
| 129 | + | </DropdownMenuItem> |
|
| 130 | + | </DialogTrigger> |
|
| 131 | + | <DropdownMenuItem |
|
| 132 | + | onClick={() => setIsRestoreDialogOpen(true)} |
|
| 133 | + | > |
|
| 134 | + | <Upload /> |
|
| 135 | + | Restore |
|
| 136 | + | </DropdownMenuItem> |
|
| 137 | + | </DropdownMenuGroup> |
|
| 138 | + | <DropdownMenuSeparator /> |
|
| 139 | + | <DropdownMenuGroup> |
|
| 140 | + | <DropdownMenuItem> |
|
| 141 | + | <BadgeCheck /> |
|
| 142 | + | Account |
|
| 143 | + | </DropdownMenuItem> |
|
| 144 | + | <DropdownMenuItem> |
|
| 145 | + | <CreditCard /> |
|
| 146 | + | Billing |
|
| 147 | + | </DropdownMenuItem> |
|
| 148 | + | <DropdownMenuItem> |
|
| 149 | + | <Bell /> |
|
| 150 | + | Notifications |
|
| 151 | + | </DropdownMenuItem> |
|
| 152 | + | </DropdownMenuGroup> |
|
| 153 | + | <DropdownMenuSeparator /> |
|
| 154 | + | <DropdownMenuItem> |
|
| 155 | + | <LogOut /> |
|
| 156 | + | Log out |
|
| 157 | + | </DropdownMenuItem> |
|
| 158 | + | </DropdownMenuContent> |
|
| 159 | + | </DropdownMenu> |
|
| 160 | + | </SidebarMenuItem> |
|
| 161 | + | </SidebarMenu> |
|
| 162 | + | <DialogContent> |
|
| 163 | + | <DialogHeader> |
|
| 164 | + | <DialogTitle>Backup or Export Your Account</DialogTitle> |
|
| 165 | + | <DialogDescription> |
|
| 166 | + | Alcove does not have access to your data since it's encrypted. In |
|
| 167 | + | order to recover it or access it from another device you need to |
|
| 168 | + | copy the phrase below somewhere safe. |
|
| 169 | + | </DialogDescription> |
|
| 170 | + | </DialogHeader> |
|
| 171 | + | <div className="space-y-4"> |
|
| 172 | + | <div className="relative p-4 bg-muted rounded-lg font-mono text-sm break-all"> |
|
| 173 | + | {isRevealed ? backupPhrase : maskPhrase(backupPhrase)} |
|
| 174 | + | </div> |
|
| 175 | + | <div className="flex gap-2"> |
|
| 176 | + | <Button |
|
| 177 | + | variant="outline" |
|
| 178 | + | size="sm" |
|
| 179 | + | onClick={() => setIsRevealed(!isRevealed)} |
|
| 180 | + | className="flex-1" |
|
| 181 | + | > |
|
| 182 | + | {isRevealed ? ( |
|
| 183 | + | <> |
|
| 184 | + | <EyeOff className="h-4 w-4 mr-2" /> |
|
| 185 | + | Hide |
|
| 186 | + | </> |
|
| 187 | + | ) : ( |
|
| 188 | + | <> |
|
| 189 | + | <Eye className="h-4 w-4 mr-2" /> |
|
| 190 | + | Reveal |
|
| 191 | + | </> |
|
| 192 | + | )} |
|
| 193 | + | </Button> |
|
| 194 | + | <Button |
|
| 195 | + | variant="outline" |
|
| 196 | + | size="sm" |
|
| 197 | + | onClick={copyToClipboard} |
|
| 198 | + | className="flex-1" |
|
| 199 | + | > |
|
| 200 | + | <Copy className="h-4 w-4 mr-2" /> |
|
| 201 | + | {isCopied ? "Copied!" : "Copy"} |
|
| 202 | + | </Button> |
|
| 203 | + | </div> |
|
| 204 | + | </div> |
|
| 205 | + | </DialogContent> |
|
| 206 | + | </Dialog> |
|
| 207 | + | <Dialog |
|
| 208 | + | open={isRestoreDialogOpen} |
|
| 209 | + | onOpenChange={handleRestoreDialogOpenChange} |
|
| 210 | + | > |
|
| 211 | + | <DialogContent> |
|
| 212 | + | <DialogHeader> |
|
| 213 | + | <DialogTitle>Restore from Backup</DialogTitle> |
|
| 214 | + | <DialogDescription> |
|
| 215 | + | Enter your backup phrase to restore your account and access your |
|
| 216 | + | encrypted data. |
|
| 217 | + | </DialogDescription> |
|
| 218 | + | </DialogHeader> |
|
| 219 | + | <div className="space-y-4"> |
|
| 220 | + | <textarea |
|
| 221 | + | className="w-full p-4 bg-muted rounded-lg font-mono text-sm resize-none min-h-[100px]" |
|
| 222 | + | placeholder="Enter your backup phrase here..." |
|
| 223 | + | value={restoreMnemonic} |
|
| 224 | + | onChange={(e) => setRestoreMnemonic(e.target.value)} |
|
| 225 | + | /> |
|
| 226 | + | <Button |
|
| 227 | + | onClick={handleRestore} |
|
| 228 | + | disabled={!restoreMnemonic.trim()} |
|
| 229 | + | className="w-full" |
|
| 39 | 230 | > |
|
| 40 | - | <div className="grid flex-1 text-center text-sm leading-tight"> |
|
| 41 | - | <span className="truncate font-medium">Settings</span> |
|
| 42 | - | </div> |
|
| 43 | - | </SidebarMenuButton> |
|
| 44 | - | </DropdownMenuTrigger> |
|
| 45 | - | <DropdownMenuContent |
|
| 46 | - | className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg" |
|
| 47 | - | side={isMobile ? "bottom" : "right"} |
|
| 48 | - | align="end" |
|
| 49 | - | sideOffset={4} |
|
| 50 | - | > |
|
| 51 | - | <DropdownMenuLabel className="p-0 font-normal"> |
|
| 52 | - | <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> |
|
| 53 | - | <Avatar className="h-8 w-8 rounded-lg"> |
|
| 54 | - | <AvatarImage src={user.avatar} alt={user.name} /> |
|
| 55 | - | <AvatarFallback className="rounded-lg">CN</AvatarFallback> |
|
| 56 | - | </Avatar> |
|
| 57 | - | <div className="grid flex-1 text-left text-sm leading-tight"> |
|
| 58 | - | <span className="truncate font-medium">{user.name}</span> |
|
| 59 | - | <span className="truncate text-xs">{user.email}</span> |
|
| 60 | - | </div> |
|
| 61 | - | </div> |
|
| 62 | - | </DropdownMenuLabel> |
|
| 63 | - | <DropdownMenuSeparator /> |
|
| 64 | - | <DropdownMenuGroup> |
|
| 65 | - | <DropdownMenuItem> |
|
| 66 | - | <Sparkles /> |
|
| 67 | - | Upgrade to Pro |
|
| 68 | - | </DropdownMenuItem> |
|
| 69 | - | </DropdownMenuGroup> |
|
| 70 | - | <DropdownMenuSeparator /> |
|
| 71 | - | <DropdownMenuGroup> |
|
| 72 | - | <DropdownMenuItem> |
|
| 73 | - | <BadgeCheck /> |
|
| 74 | - | Account |
|
| 75 | - | </DropdownMenuItem> |
|
| 76 | - | <DropdownMenuItem> |
|
| 77 | - | <CreditCard /> |
|
| 78 | - | Billing |
|
| 79 | - | </DropdownMenuItem> |
|
| 80 | - | <DropdownMenuItem> |
|
| 81 | - | <Bell /> |
|
| 82 | - | Notifications |
|
| 83 | - | </DropdownMenuItem> |
|
| 84 | - | </DropdownMenuGroup> |
|
| 85 | - | <DropdownMenuSeparator /> |
|
| 86 | - | <DropdownMenuItem> |
|
| 87 | - | <LogOut /> |
|
| 88 | - | Log out |
|
| 89 | - | </DropdownMenuItem> |
|
| 90 | - | </DropdownMenuContent> |
|
| 91 | - | </DropdownMenu> |
|
| 92 | - | </SidebarMenuItem> |
|
| 93 | - | </SidebarMenu> |
|
| 231 | + | <Upload className="h-4 w-4 mr-2" /> |
|
| 232 | + | Restore Account |
|
| 233 | + | </Button> |
|
| 234 | + | </div> |
|
| 235 | + | </DialogContent> |
|
| 236 | + | </Dialog> |
|
| 237 | + | </> |
|
| 94 | 238 | ); |
|
| 95 | 239 | } |
|
| 17 | 17 | reloadUrl: "/", |
|
| 18 | 18 | encryptionKey: authResult?.owner?.encryptionKey, |
|
| 19 | 19 | externalAppOwner: authResult?.owner, |
|
| 20 | - | transports: [{ type: "WebSocket", url: import.meta.env.VITE_RELAY_URL }], |
|
| 20 | + | transports: [ |
|
| 21 | + | { |
|
| 22 | + | type: "WebSocket", |
|
| 23 | + | url: "wss://relay.alcove.tools", |
|
| 24 | + | }, |
|
| 25 | + | ], |
|
| 21 | 26 | }); |
|
| 22 | 27 | ||
| 23 | 28 | export const useEvolu = createUseEvolu(evolu); |