From af35c67743cf0a26b94fe847ef3b9bb1a06de620 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Thu, 27 Aug 2026 10:50:34 +0700 Subject: [PATCH 1/2] [Dashboard] Guard the vault rotation dialog against losing new keys Rotation returns an ejected vault's admin key and wallet access token once and keeps no copy. - Make the dialog non-dismissable while the request is in flight and while unconfirmed keys are on screen. The old guard derived from mutation data, so it did nothing during the request. - Warn on unload in those same two states. - Branch the result on isManagedVault rather than on which fields came back. An ejected response missing either credential now renders an explicit error state instead of the managed success panel. Co-Authored-By: Claude Fable 5 --- .../components/rotate-admin-key.client.tsx | 138 +++++++++++++++--- 1 file changed, 117 insertions(+), 21 deletions(-) diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx index 4dbe75513ac..d138e7839ef 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx @@ -9,7 +9,7 @@ import { LogOutIcon, RefreshCcwIcon, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { rotateVaultServiceAccount } from "@/actions/vault"; import type { Project } from "@/api/project/projects"; @@ -61,22 +61,55 @@ export default function RotateAdminKeyButton(props: { }, }); - // Only an ejected vault returns the new keys; a managed vault keeps them. - const rotatedKeys = - rotateAdminKeyMutation.data?.adminKey && - rotateAdminKeyMutation.data.walletAccessToken + const rotationResult = rotateAdminKeyMutation.data; + + // An ejected vault keeps no server-side copy of its credentials, so this + // response is the only time they exist outside the enclave. A managed vault + // re-seals them with the project secret key and returns only the mask. + const ejectedKeys = + rotationResult && + !rotationResult.isManagedVault && + rotationResult.adminKey && + rotationResult.walletAccessToken ? { - adminKey: rotateAdminKeyMutation.data.adminKey, - walletAccessToken: rotateAdminKeyMutation.data.walletAccessToken, + adminKey: rotationResult.adminKey, + walletAccessToken: rotationResult.walletAccessToken, } : undefined; + // The rotation went through but the response is missing values an ejected + // vault must return. Nothing can re-derive them, so this is a dead end. + const ejectedKeysMissing = + !!rotationResult && !rotationResult.isManagedVault && !ejectedKeys; + + // Closing discards the only copy of the new credentials: while the request is + // in flight the response has nowhere to land, and once it lands the user has + // not stored it yet. Both states must hold the dialog open. + const closeBlocked = + rotateAdminKeyMutation.isPending || (!!ejectedKeys && !keysConfirmed); + + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + if (!closeBlocked) { + return; + } + + const warnBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + // Required by Safari and older Chrome to show the prompt. + event.returnValue = ""; + }; + + window.addEventListener("beforeunload", warnBeforeUnload); + return () => window.removeEventListener("beforeunload", warnBeforeUnload); + }, [closeBlocked]); + const handleDownloadKeys = () => { - if (!rotatedKeys) { + if (!ejectedKeys) { return; } - const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${rotatedKeys.adminKey}\n\nVault Access Token:\n${rotatedKeys.walletAccessToken}\n`; + const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${ejectedKeys.adminKey}\n\nVault Access Token:\n${ejectedKeys.walletAccessToken}\n`; const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -95,7 +128,7 @@ export default function RotateAdminKeyButton(props: { }; const handleCloseModal = () => { - if (rotatedKeys && !keysConfirmed) { + if (closeBlocked) { return; } @@ -129,10 +162,33 @@ export default function RotateAdminKeyButton(props: { Rotate Admin Key - + { + if (!open) { + handleCloseModal(); + } + }} + open={modalOpen} + > { + if (closeBlocked) { + event.preventDefault(); + } + }} + onInteractOutside={(event) => { + if (closeBlocked) { + event.preventDefault(); + } + }} + onPointerDownOutside={(event) => { + if (closeBlocked) { + event.preventDefault(); + } + }} > {rotateAdminKeyMutation.isPending ? ( <> @@ -142,11 +198,11 @@ export default function RotateAdminKeyButton(props: {

- This may take a few seconds. + This may take a few seconds. Keep this dialog open.

- ) : rotatedKeys ? ( + ) : ejectedKeys ? (
New Vault Keys @@ -162,8 +218,8 @@ export default function RotateAdminKeyButton(props: {

@@ -180,8 +236,8 @@ export default function RotateAdminKeyButton(props: {

@@ -235,7 +291,47 @@ export default function RotateAdminKeyButton(props: {

- ) : rotateAdminKeyMutation.data ? ( + ) : ejectedKeysMissing ? ( +
+ + Rotation completed without new keys + + +
+ + + New keys were not returned + + The rotation went through, so your previous admin key and + access tokens no longer work, but the replacements did not + reach this page and cannot be shown again. Your vault and + server wallets are unaffected. Contact support to have new + credentials issued for this project. + + + + {rotationResult?.maskedAdminKey && ( +
+

+ New Vault Admin Key +

+
+ {rotationResult.maskedAdminKey} +
+

+ Give support this masked key to identify the rotation. +

+
+ )} +
+ +
+ +
+
+ ) : rotationResult?.isManagedVault ? (
Admin Key Rotated @@ -248,7 +344,7 @@ export default function RotateAdminKeyButton(props: {
- {rotateAdminKeyMutation.data.maskedAdminKey} + {rotationResult.maskedAdminKey}

Your admin key and wallet access token were re-encrypted @@ -344,7 +440,7 @@ export default function RotateAdminKeyButton(props: { {willStayManaged ? "This will invalidate your current admin key and all existing access tokens. Your stored credentials will be re-encrypted automatically, so your server wallets keep working." - : "This action will invalidate your current admin key and all existing access tokens. You will need to update your backend to use these new access tokens."} + : "This action will invalidate your current admin key and all existing access tokens. You will need to update your backend to use these new access tokens. The new keys are shown once and cannot be recovered, so keep this dialog open until you have stored them."}

From d771fc7e91a6a9a805f2f5fc356456d16528b838 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Thu, 27 Aug 2026 11:09:34 +0700 Subject: [PATCH 2/2] [Dashboard] Require the new keys to be captured before confirming Closing the dialog discards the response, and for an ejected vault that response is the only copy of the new keys. Blocking Escape, the backdrop and the close control left the confirm checkbox as the only way out, so an impatient click could still drop them. The checkbox is now disabled until the keys have actually left the screen: either downloaded, or both values copied. Co-Authored-By: Claude Fable 5 --- .../vault/components/rotate-admin-key.client.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx index d138e7839ef..e4dc1489e4d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/vault/components/rotate-admin-key.client.tsx @@ -38,6 +38,8 @@ export default function RotateAdminKeyButton(props: { const [modalOpen, setModalOpen] = useState(false); const [keysConfirmed, setKeysConfirmed] = useState(false); const [keysDownloaded, setKeysDownloaded] = useState(false); + const [adminKeyCopied, setAdminKeyCopied] = useState(false); + const [accessTokenCopied, setAccessTokenCopied] = useState(false); const [stayManaged, setStayManaged] = useState(props.isManagedVault); const [secretKeyInput, setSecretKeyInput] = useState(""); const router = useDashboardRouter(); @@ -85,6 +87,12 @@ export default function RotateAdminKeyButton(props: { // Closing discards the only copy of the new credentials: while the request is // in flight the response has nowhere to land, and once it lands the user has // not stored it yet. Both states must hold the dialog open. + // The confirm checkbox is the only way out of this dialog, and closing it + // discards the response. Require the keys to actually leave the screen + // first: a download, or a copy of both values. + const keysCaptured = + keysDownloaded || (adminKeyCopied && accessTokenCopied); + const closeBlocked = rotateAdminKeyMutation.isPending || (!!ejectedKeys && !keysConfirmed); @@ -218,6 +226,7 @@ export default function RotateAdminKeyButton(props: { setAdminKeyCopied(true)} textToCopy={ejectedKeys.adminKey} textToShow={maskSecret(ejectedKeys.adminKey)} tooltip="Copy Admin Key" @@ -236,6 +245,7 @@ export default function RotateAdminKeyButton(props: { setAccessTokenCopied(true)} textToCopy={ejectedKeys.walletAccessToken} textToShow={maskSecret(ejectedKeys.walletAccessToken)} tooltip="Copy Vault Access Token" @@ -274,10 +284,16 @@ export default function RotateAdminKeyButton(props: { setKeysConfirmed(!!v)} /> I confirm that I've securely stored these keys + {!keysCaptured && ( +

+ Download the keys, or copy both values, to continue. +

+ )}