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.