feat: added backup and restore a9a64c8f
Steve · 2025-11-02 21:09 2 file(s) · +221 −72
src/components/nav-user.tsx +215 −71
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
}
src/lib/evolu.ts +6 −1
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);