From 8dd07598a192ab0b537c2ea138879cd3f42f7af0 Mon Sep 17 00:00:00 2001 From: MananTank Date: Tue, 28 Jan 2025 21:43:41 +0000 Subject: [PATCH] [TOOL-3251] Dashboard: Add Rotate Secret Key feature in Project Settings page (#6087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR-Codex overview This PR focuses on enhancing the secret key management functionality within the project settings. It introduces the ability to rotate secret keys, updates related UI components, and improves user notifications regarding key management. ### Detailed summary - Added `projectId` prop to relevant components. - Implemented `rotateSecretKey` function for API integration. - Updated UI to reflect secret key rotation capabilities. - Enhanced user alerts and confirmations regarding secret key management. - Created modal components for rotating and saving new secret keys. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../ProjectGeneralSettingsPage.stories.tsx | 10 + .../settings/ProjectGeneralSettingsPage.tsx | 281 +++++++++++++++++- .../ProjectGeneralSettingsPageForTeams.tsx | 4 +- .../[project_slug]/settings/page.tsx | 1 + .../settings/ApiKeys/Create/index.tsx | 2 +- 5 files changed, 290 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx index b75798bdd91..af88097f2de 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx @@ -60,6 +60,16 @@ function Story() { payConfig: "/payConfig", }} onKeyUpdated={undefined} + rotateSecretKey={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + data: { + secret: new Array(86).fill("x").join(""), + secretHash: new Array(64).fill("x").join(""), + secretMasked: "123...4567", + }, + }; + }} showNebulaSettings={false} /> diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx index 88423755106..441cdf9f563 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx @@ -1,12 +1,21 @@ "use client"; +import { apiServerProxy } from "@/actions/proxies"; import { DangerSettingCard } from "@/components/blocks/DangerSettingCard"; import { SettingsCard } from "@/components/blocks/SettingsCard"; import { CopyTextButton } from "@/components/ui/CopyTextButton"; import { DynamicHeight } from "@/components/ui/DynamicHeight"; +import { Spinner } from "@/components/ui/Spinner/Spinner"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Form } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -14,13 +23,14 @@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ToolTipLabel } from "@/components/ui/tooltip"; import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { cn } from "@/lib/utils"; import type { ApiKey, UpdateKeyInput } from "@3rdweb-sdk/react/hooks/useApi"; import { useRevokeApiKey, useUpdateApiKey, } from "@3rdweb-sdk/react/hooks/useApi"; import { zodResolver } from "@hookform/resolvers/zod"; -import type { UseMutationResult } from "@tanstack/react-query"; +import { type UseMutationResult, useMutation } from "@tanstack/react-query"; import { SERVICES } from "@thirdweb-dev/service-utils"; import { type ServiceName, @@ -28,8 +38,13 @@ import { } from "@thirdweb-dev/service-utils"; import { format } from "date-fns"; import { useTrack } from "hooks/analytics/useTrack"; -import { ExternalLinkIcon } from "lucide-react"; +import { + CircleAlertIcon, + ExternalLinkIcon, + RefreshCcwIcon, +} from "lucide-react"; import Link from "next/link"; +import { useState } from "react"; import { type UseFormReturn, useForm } from "react-hook-form"; import { type FieldArrayWithId, useFieldArray } from "react-hook-form"; import { toast } from "sonner"; @@ -47,11 +62,20 @@ type EditProjectUIPaths = { afterDeleteRedirectTo: string; }; +type RotateSecretKeyAPIReturnType = { + data: { + secret: string; + secretMasked: string; + secretHash: string; + }; +}; + export function ProjectGeneralSettingsPage(props: { apiKey: ApiKey; paths: EditProjectUIPaths; onKeyUpdated: (() => void) | undefined; showNebulaSettings: boolean; + projectId: string; }) { const updateMutation = useUpdateApiKey(); const deleteMutation = useRevokeApiKey(); @@ -64,6 +88,24 @@ export function ProjectGeneralSettingsPage(props: { paths={props.paths} onKeyUpdated={props.onKeyUpdated} showNebulaSettings={props.showNebulaSettings} + rotateSecretKey={async () => { + const res = await apiServerProxy({ + pathname: "/v2/keys/rotate-secret-key", + method: "POST", + body: JSON.stringify({ + projectId: props.projectId, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + throw new Error(res.error); + } + + return res.data; + }} /> ); } @@ -84,6 +126,7 @@ interface EditApiKeyProps { paths: EditProjectUIPaths; onKeyUpdated: (() => void) | undefined; showNebulaSettings: boolean; + rotateSecretKey: () => Promise; } type UpdateAPIForm = UseFormReturn; @@ -216,7 +259,10 @@ export const ProjectGeneralSettingsPageUI: React.FC = ( handleSubmit={handleSubmit} /> - + Promise; apiKey: ApiKey; }) { const { createdAt, updatedAt, lastAccessedAt } = apiKey; + const [secretKeyMasked, setSecretKeyMasked] = useState(apiKey.secretMasked); return (
@@ -632,7 +681,7 @@ function APIKeyDetails({
{/* NOTE: for very old api keys the secret might be `null`, if that's the case we skip it */} - {apiKey.secretMasked && ( + {secretKeyMasked && (

Secret Key

@@ -641,8 +690,17 @@ function APIKeyDetails({ the time of creation for the full secret key.

-
- {apiKey.secretMasked} +
+
+ {secretKeyMasked} +
+ + { + setSecretKeyMasked(data.data.secretMasked); + }} + />
)} @@ -726,3 +784,214 @@ function DeleteProject(props: { /> ); } + +function RotateSecretKeyButton(props: { + rotateSecretKey: () => Promise; + onSuccess: (data: RotateSecretKeyAPIReturnType) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const [isModalCloseAllowed, setIsModalCloseAllowed] = useState(true); + return ( + { + if (!isModalCloseAllowed) { + return; + } + setIsOpen(v); + }} + > + + + + + + { + setIsOpen(false); + setIsModalCloseAllowed(true); + }} + disableModalClose={() => setIsModalCloseAllowed(false)} + onSuccess={props.onSuccess} + /> + + + ); +} + +type RotateSecretKeyScreen = + | { id: "initial" } + | { id: "save-newkey"; secretKey: string }; + +function RotateSecretKeyModalContent(props: { + rotateSecretKey: () => Promise; + closeModal: () => void; + disableModalClose: () => void; + onSuccess: (data: RotateSecretKeyAPIReturnType) => void; +}) { + const [screen, setScreen] = useState({ + id: "initial", + }); + + if (screen.id === "save-newkey") { + return ( + + ); + } + + if (screen.id === "initial") { + return ( + { + props.disableModalClose(); + props.onSuccess(data); + setScreen({ id: "save-newkey", secretKey: data.data.secret }); + }} + closeModal={props.closeModal} + /> + ); + } + + return null; +} + +function RotateSecretKeyInitialScreen(props: { + rotateSecretKey: () => Promise; + onSuccess: (data: RotateSecretKeyAPIReturnType) => void; + closeModal: () => void; +}) { + const [isConfirmed, setIsConfirmed] = useState(false); + const rotateKeyMutation = useMutation({ + mutationFn: props.rotateSecretKey, + onSuccess: (data) => { + props.onSuccess(data); + }, + onError: (err) => { + console.error(err); + toast.error("Failed to rotate secret key"); + }, + }); + return ( +
+
+ + Rotate Secret Key + + +
+ + + + Current secret key will stop working + + Rotating the secret key will invalidate the current secret key and + generate a new one. This action is irreversible. + + + +
+ + + setIsConfirmed(!!v)} + /> + I understand the consequences of rotating the secret key + +
+ +
+ + +
+
+ ); +} + +function SaveNewKeyScreen(props: { + secretKey: string; + closeModal: () => void; +}) { + const [isSecretStored, setIsSecretStored] = useState(false); + return ( +
+
+ + Save New Secret Key + + +
+ + +
+ + + Do not share or expose your secret key + +
+ Secret keys cannot be recovered. If you lose your secret key, you + will need to rotate the secret key or create a new Project. +
+ + { + setIsSecretStored(!!v); + }} + /> + I confirm that I've securely stored my secret key + +
+
+
+ +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPageForTeams.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPageForTeams.tsx index 5b7d7e0280a..171a7acdede 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPageForTeams.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPageForTeams.tsx @@ -9,9 +9,10 @@ export function ProjectGeneralSettingsPageForTeams(props: { team: Team; project_slug: string; apiKey: ApiKey; + projectId: string; }) { const router = useDashboardRouter(); - const { team, project_slug, apiKey } = props; + const { team, project_slug, apiKey, projectId } = props; const projectLayout = `/team/${team.slug}/${project_slug}`; // TODO - add a Project Image form field on this page @@ -19,6 +20,7 @@ export function ProjectGeneralSettingsPageForTeams(props: { return ( ); diff --git a/apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx b/apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx index 987916c813a..f0737b70e3d 100644 --- a/apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx +++ b/apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx @@ -486,7 +486,7 @@ function APIKeyDetails(props: {
Secret keys cannot be recovered. If you lose your secret key, you - will need to create a project + will need to rotate the secret key or create a new Project.