Skip to content

Commit 9cd153f

Browse files
committed
Add admin key download flow for ejected vault recovery
Implements a secure admin key download and confirmation step when regenerating server wallet configuration for ejected vaults. The UI now prompts users to download and confirm saving the admin key before proceeding, ensuring keys are not lost. Managed vaults retain the previous secret key input flow.
1 parent d1696df commit 9cd153f

File tree

1 file changed

+216
-89
lines changed

1 file changed

+216
-89
lines changed

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/wallets/server-wallets/wallets/vault-recovery-card.client.tsx

Lines changed: 216 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
"use client";
22

33
import { useMutation } from "@tanstack/react-query";
4-
import { AlertTriangleIcon, RotateCcwIcon } from "lucide-react";
4+
import {
5+
AlertTriangleIcon,
6+
CheckIcon,
7+
DownloadIcon,
8+
RotateCcwIcon,
9+
} from "lucide-react";
510
import { useState } from "react";
11+
import { toast } from "sonner";
612
import type { Project } from "@/api/project/projects";
713
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
814
import {
@@ -17,12 +23,14 @@ import {
1723
AlertDialogTrigger,
1824
} from "@/components/ui/alert-dialog";
1925
import { Button } from "@/components/ui/button";
20-
import { Checkbox } from "@/components/ui/checkbox";
26+
import { CopyTextButton } from "@/components/ui/CopyTextButton";
27+
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
2128
import { Input } from "@/components/ui/input";
2229
import { Spinner } from "@/components/ui/Spinner";
2330
import {
2431
createVaultAccountAndAccessToken,
2532
initVaultClient,
33+
maskSecret,
2634
} from "../../../transactions/lib/vault.client";
2735

2836
interface VaultRecoveryCardProps {
@@ -37,6 +45,9 @@ export function VaultRecoveryCard({
3745
const [dialogOpen, setDialogOpen] = useState(false);
3846
const [confirmed, setConfirmed] = useState(false);
3947
const [secretKeyInput, setSecretKeyInput] = useState("");
48+
// For ejected vault key download flow
49+
const [keysConfirmed, setKeysConfirmed] = useState(false);
50+
const [keysDownloaded, setKeysDownloaded] = useState(false);
4051

4152
// Check if this was a managed vault (had encryptedAdminKey)
4253
const engineCloudService = project.services.find(
@@ -61,11 +72,44 @@ export function VaultRecoveryCard({
6172
return result;
6273
},
6374
onSuccess: () => {
64-
// Refresh the page to show the new wallet state
65-
window.location.reload();
75+
// For managed vaults, reload immediately (keys are encrypted with secret key)
76+
// For ejected vaults, show the key download dialog first
77+
if (wasManagedVault) {
78+
window.location.reload();
79+
}
80+
// For ejected vaults, we stay in the dialog to show the admin key
6681
},
6782
});
6883

84+
const handleDownloadKeys = () => {
85+
if (!regenerateMutation.data) {
86+
return;
87+
}
88+
89+
const fileContent = `Project:\n${project.name} (${project.publishableKey})\n\nVault Admin Key:\n${regenerateMutation.data.adminKey}\n\nVault Access Token:\n${regenerateMutation.data.walletToken.accessToken}\n`;
90+
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
91+
const url = URL.createObjectURL(blob);
92+
const link = document.createElement("a");
93+
const filename = `${project.name}-vault-keys.txt`;
94+
link.href = url;
95+
link.download = filename;
96+
document.body.appendChild(link);
97+
link.click();
98+
99+
document.body.removeChild(link);
100+
URL.revokeObjectURL(url);
101+
102+
toast.success(`Keys downloaded as ${filename}`);
103+
setKeysDownloaded(true);
104+
};
105+
106+
const handleCloseAfterKeysSaved = () => {
107+
if (!keysConfirmed) {
108+
return;
109+
}
110+
window.location.reload();
111+
};
112+
69113
// For managed vaults, require secret key input
70114
const canProceed = wasManagedVault
71115
? confirmed && secretKeyInput.trim().length > 0
@@ -119,93 +163,176 @@ export function VaultRecoveryCard({
119163
</Button>
120164
</AlertDialogTrigger>
121165
<AlertDialogContent>
122-
<AlertDialogHeader>
123-
<AlertDialogTitle>
124-
Create New Server Wallet Configuration?
125-
</AlertDialogTitle>
126-
<AlertDialogDescription asChild>
127-
<div className="space-y-3">
128-
<p>
129-
This will create a completely new server wallet
130-
configuration for your project.
131-
</p>
132-
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
133-
<p className="font-semibold text-destructive-text text-sm">
134-
Warning: This action cannot be undone
135-
</p>
136-
<ul className="mt-2 list-inside list-disc space-y-1 text-muted-foreground text-sm">
137-
<li>
138-
Any existing server wallets will be permanently
139-
inaccessible
140-
</li>
141-
<li>
142-
You will need to create new wallets after this process
143-
</li>
144-
</ul>
145-
</div>
146-
147-
{wasManagedVault && (
148-
<div className="space-y-2">
149-
<label
150-
htmlFor="secret-key-input"
151-
className="font-medium text-sm"
152-
>
153-
Enter your project Secret Key
154-
</label>
155-
<Input
156-
id="secret-key-input"
157-
type="password"
158-
placeholder="sk_..."
159-
value={secretKeyInput}
160-
onChange={(e) => setSecretKeyInput(e.target.value)}
161-
/>
162-
<p className="text-muted-foreground text-xs">
163-
Your secret key is required to create a managed vault.
166+
{/* Show key download UI for ejected vaults after success */}
167+
{!wasManagedVault && regenerateMutation.data ? (
168+
<>
169+
<AlertDialogHeader>
170+
<AlertDialogTitle>Save your Vault Admin Key</AlertDialogTitle>
171+
<AlertDialogDescription asChild>
172+
<div className="space-y-4">
173+
<p>
174+
You&apos;ll need this key to create server wallets and
175+
access tokens.
164176
</p>
177+
178+
<div className="space-y-2">
179+
<CopyTextButton
180+
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
181+
copyIconPosition="right"
182+
textToCopy={regenerateMutation.data.adminKey}
183+
textToShow={maskSecret(
184+
regenerateMutation.data.adminKey,
185+
)}
186+
tooltip="Copy Admin Key"
187+
/>
188+
<p className="text-muted-foreground text-xs">
189+
Download this key to your local machine or a password
190+
manager.
191+
</p>
192+
</div>
193+
194+
<Alert variant="destructive">
195+
<AlertTitle>Secure your admin key</AlertTitle>
196+
<AlertDescription>
197+
This key will not be displayed again. Store it
198+
securely as it provides access to your server wallets.
199+
</AlertDescription>
200+
<div className="mt-4 flex items-center gap-2">
201+
<Button
202+
className="flex h-auto items-center gap-2 p-0 text-sm text-success-text"
203+
onClick={handleDownloadKeys}
204+
variant="link"
205+
>
206+
<DownloadIcon className="size-4" />
207+
{keysDownloaded
208+
? "Key Downloaded"
209+
: "Download Admin Key"}
210+
</Button>
211+
{keysDownloaded && (
212+
<span className="text-success-text text-xs">
213+
<CheckIcon className="size-4" />
214+
</span>
215+
)}
216+
</div>
217+
<div className="mt-4">
218+
<CheckboxWithLabel className="text-foreground">
219+
<Checkbox
220+
checked={keysConfirmed}
221+
onCheckedChange={(v) => setKeysConfirmed(!!v)}
222+
/>
223+
I confirm that I&apos;ve securely stored my admin
224+
key
225+
</CheckboxWithLabel>
226+
</div>
227+
</Alert>
228+
</div>
229+
</AlertDialogDescription>
230+
</AlertDialogHeader>
231+
<AlertDialogFooter>
232+
<AlertDialogAction
233+
disabled={!keysConfirmed}
234+
onClick={handleCloseAfterKeysSaved}
235+
variant="primary"
236+
>
237+
Close
238+
</AlertDialogAction>
239+
</AlertDialogFooter>
240+
</>
241+
) : (
242+
<>
243+
<AlertDialogHeader>
244+
<AlertDialogTitle>
245+
Create New Server Wallet Configuration?
246+
</AlertDialogTitle>
247+
<AlertDialogDescription asChild>
248+
<div className="space-y-3">
249+
<p>
250+
This will create a completely new server wallet
251+
configuration for your project.
252+
</p>
253+
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-3">
254+
<p className="font-semibold text-destructive-text text-sm">
255+
Warning: This action cannot be undone
256+
</p>
257+
<ul className="mt-2 list-inside list-disc space-y-1 text-muted-foreground text-sm">
258+
<li>
259+
Any existing server wallets will be permanently
260+
inaccessible
261+
</li>
262+
<li>
263+
You will need to create new wallets after this
264+
process
265+
</li>
266+
</ul>
267+
</div>
268+
269+
{wasManagedVault && (
270+
<div className="space-y-2">
271+
<label
272+
htmlFor="secret-key-input"
273+
className="font-medium text-sm"
274+
>
275+
Enter your project Secret Key
276+
</label>
277+
<Input
278+
id="secret-key-input"
279+
type="password"
280+
placeholder="sk_..."
281+
value={secretKeyInput}
282+
onChange={(e) => setSecretKeyInput(e.target.value)}
283+
/>
284+
<p className="text-muted-foreground text-xs">
285+
Your secret key is required to create a managed
286+
vault.
287+
</p>
288+
</div>
289+
)}
290+
291+
<div className="flex items-start gap-2 pt-2">
292+
<Checkbox
293+
id="confirm-recovery"
294+
checked={confirmed}
295+
onCheckedChange={(checked) =>
296+
setConfirmed(checked === true)
297+
}
298+
/>
299+
<label
300+
htmlFor="confirm-recovery"
301+
className="cursor-pointer text-sm leading-tight"
302+
>
303+
I understand that existing server wallets will not be
304+
recoverable and I want to proceed
305+
</label>
306+
</div>
165307
</div>
166-
)}
167-
168-
<div className="flex items-start gap-2 pt-2">
169-
<Checkbox
170-
id="confirm-recovery"
171-
checked={confirmed}
172-
onCheckedChange={(checked) =>
173-
setConfirmed(checked === true)
174-
}
175-
/>
176-
<label
177-
htmlFor="confirm-recovery"
178-
className="cursor-pointer text-sm leading-tight"
179-
>
180-
I understand that existing server wallets will not be
181-
recoverable and I want to proceed
182-
</label>
183-
</div>
184-
</div>
185-
</AlertDialogDescription>
186-
</AlertDialogHeader>
187-
<AlertDialogFooter>
188-
<AlertDialogCancel
189-
onClick={() => {
190-
setConfirmed(false);
191-
setSecretKeyInput("");
192-
}}
193-
>
194-
Cancel
195-
</AlertDialogCancel>
196-
<AlertDialogAction
197-
disabled={!canProceed || regenerateMutation.isPending}
198-
onClick={(e) => {
199-
e.preventDefault();
200-
regenerateMutation.mutate();
201-
}}
202-
variant="destructive"
203-
className="gap-2"
204-
>
205-
{regenerateMutation.isPending && <Spinner className="size-4" />}
206-
Create New Wallet Configuration
207-
</AlertDialogAction>
208-
</AlertDialogFooter>
308+
</AlertDialogDescription>
309+
</AlertDialogHeader>
310+
<AlertDialogFooter>
311+
<AlertDialogCancel
312+
onClick={() => {
313+
setConfirmed(false);
314+
setSecretKeyInput("");
315+
}}
316+
>
317+
Cancel
318+
</AlertDialogCancel>
319+
<AlertDialogAction
320+
disabled={!canProceed || regenerateMutation.isPending}
321+
onClick={(e) => {
322+
e.preventDefault();
323+
regenerateMutation.mutate();
324+
}}
325+
variant="destructive"
326+
className="gap-2"
327+
>
328+
{regenerateMutation.isPending && (
329+
<Spinner className="size-4" />
330+
)}
331+
Create New Wallet Configuration
332+
</AlertDialogAction>
333+
</AlertDialogFooter>
334+
</>
335+
)}
209336
</AlertDialogContent>
210337
</AlertDialog>
211338

0 commit comments

Comments
 (0)