11"use client" ;
22
33import { 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" ;
510import { useState } from "react" ;
11+ import { toast } from "sonner" ;
612import type { Project } from "@/api/project/projects" ;
713import { Alert , AlertDescription , AlertTitle } from "@/components/ui/alert" ;
814import {
@@ -17,12 +23,14 @@ import {
1723 AlertDialogTrigger ,
1824} from "@/components/ui/alert-dialog" ;
1925import { 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" ;
2128import { Input } from "@/components/ui/input" ;
2229import { Spinner } from "@/components/ui/Spinner" ;
2330import {
2431 createVaultAccountAndAccessToken ,
2532 initVaultClient ,
33+ maskSecret ,
2634} from "../../../transactions/lib/vault.client" ;
2735
2836interface 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'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'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