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..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 @@ -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"; @@ -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(); @@ -61,22 +63,61 @@ 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. + // 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); + + // 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 +136,7 @@ export default function RotateAdminKeyButton(props: { }; const handleCloseModal = () => { - if (rotatedKeys && !keysConfirmed) { + if (closeBlocked) { return; } @@ -129,10 +170,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 +206,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 +226,9 @@ export default function RotateAdminKeyButton(props: { setAdminKeyCopied(true)} + textToCopy={ejectedKeys.adminKey} + textToShow={maskSecret(ejectedKeys.adminKey)} tooltip="Copy Admin Key" />

@@ -180,8 +245,9 @@ export default function RotateAdminKeyButton(props: { setAccessTokenCopied(true)} + textToCopy={ejectedKeys.walletAccessToken} + textToShow={maskSecret(ejectedKeys.walletAccessToken)} tooltip="Copy Vault Access Token" />

@@ -218,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. +

+ )}
@@ -235,7 +307,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 +360,7 @@ export default function RotateAdminKeyButton(props: {
- {rotateAdminKeyMutation.data.maskedAdminKey} + {rotationResult.maskedAdminKey}

Your admin key and wallet access token were re-encrypted @@ -344,7 +456,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."}