11"use client" ;
22
3+ import { apiServerProxy } from "@/actions/proxies" ;
34import { DangerSettingCard } from "@/components/blocks/DangerSettingCard" ;
45import { SettingsCard } from "@/components/blocks/SettingsCard" ;
56import { CopyTextButton } from "@/components/ui/CopyTextButton" ;
67import { DynamicHeight } from "@/components/ui/DynamicHeight" ;
8+ import { Spinner } from "@/components/ui/Spinner/Spinner" ;
79import { Alert , AlertDescription , AlertTitle } from "@/components/ui/alert" ;
810import { Button } from "@/components/ui/button" ;
911import { Checkbox , CheckboxWithLabel } from "@/components/ui/checkbox" ;
12+ import {
13+ Dialog ,
14+ DialogContent ,
15+ DialogHeader ,
16+ DialogTitle ,
17+ DialogTrigger ,
18+ } from "@/components/ui/dialog" ;
1019import { Form } from "@/components/ui/form" ;
1120import { Input } from "@/components/ui/input" ;
1221import { Label } from "@/components/ui/label" ;
1322import { Switch } from "@/components/ui/switch" ;
1423import { Textarea } from "@/components/ui/textarea" ;
1524import { ToolTipLabel } from "@/components/ui/tooltip" ;
1625import { useDashboardRouter } from "@/lib/DashboardRouter" ;
26+ import { cn } from "@/lib/utils" ;
1727import type { ApiKey , UpdateKeyInput } from "@3rdweb-sdk/react/hooks/useApi" ;
1828import {
1929 useRevokeApiKey ,
2030 useUpdateApiKey ,
2131} from "@3rdweb-sdk/react/hooks/useApi" ;
2232import { zodResolver } from "@hookform/resolvers/zod" ;
23- import type { UseMutationResult } from "@tanstack/react-query" ;
33+ import { type UseMutationResult , useMutation } from "@tanstack/react-query" ;
2434import { SERVICES } from "@thirdweb-dev/service-utils" ;
2535import {
2636 type ServiceName ,
2737 getServiceByName ,
2838} from "@thirdweb-dev/service-utils" ;
2939import { format } from "date-fns" ;
3040import { useTrack } from "hooks/analytics/useTrack" ;
31- import { ExternalLinkIcon } from "lucide-react" ;
41+ import {
42+ CircleAlertIcon ,
43+ ExternalLinkIcon ,
44+ RefreshCcwIcon ,
45+ } from "lucide-react" ;
3246import Link from "next/link" ;
47+ import { useState } from "react" ;
3348import { type UseFormReturn , useForm } from "react-hook-form" ;
3449import { type FieldArrayWithId , useFieldArray } from "react-hook-form" ;
3550import { toast } from "sonner" ;
@@ -47,11 +62,20 @@ type EditProjectUIPaths = {
4762 afterDeleteRedirectTo : string ;
4863} ;
4964
65+ type RotateSecretKeyAPIReturnType = {
66+ data : {
67+ secret : string ;
68+ secretMasked : string ;
69+ secretHash : string ;
70+ } ;
71+ } ;
72+
5073export function ProjectGeneralSettingsPage ( props : {
5174 apiKey : ApiKey ;
5275 paths : EditProjectUIPaths ;
5376 onKeyUpdated : ( ( ) => void ) | undefined ;
5477 showNebulaSettings : boolean ;
78+ projectId : string ;
5579} ) {
5680 const updateMutation = useUpdateApiKey ( ) ;
5781 const deleteMutation = useRevokeApiKey ( ) ;
@@ -64,6 +88,24 @@ export function ProjectGeneralSettingsPage(props: {
6488 paths = { props . paths }
6589 onKeyUpdated = { props . onKeyUpdated }
6690 showNebulaSettings = { props . showNebulaSettings }
91+ rotateSecretKey = { async ( ) => {
92+ const res = await apiServerProxy < RotateSecretKeyAPIReturnType > ( {
93+ pathname : "/v2/keys/rotate-secret-key" ,
94+ method : "POST" ,
95+ body : JSON . stringify ( {
96+ projectId : props . projectId ,
97+ } ) ,
98+ headers : {
99+ "Content-Type" : "application/json" ,
100+ } ,
101+ } ) ;
102+
103+ if ( ! res . ok ) {
104+ throw new Error ( res . error ) ;
105+ }
106+
107+ return res . data ;
108+ } }
67109 />
68110 ) ;
69111}
@@ -84,6 +126,7 @@ interface EditApiKeyProps {
84126 paths : EditProjectUIPaths ;
85127 onKeyUpdated : ( ( ) => void ) | undefined ;
86128 showNebulaSettings : boolean ;
129+ rotateSecretKey : ( ) => Promise < RotateSecretKeyAPIReturnType > ;
87130}
88131
89132type UpdateAPIForm = UseFormReturn < ProjectSettingsPageFormSchema > ;
@@ -216,7 +259,10 @@ export const ProjectGeneralSettingsPageUI: React.FC<EditApiKeyProps> = (
216259 handleSubmit = { handleSubmit }
217260 />
218261
219- < APIKeyDetails apiKey = { apiKey } />
262+ < APIKeyDetails
263+ apiKey = { apiKey }
264+ rotateSecretKey = { props . rotateSecretKey }
265+ />
220266
221267 < AllowedDomainsSetting
222268 form = { form }
@@ -609,10 +655,13 @@ function EnabledServicesSetting(props: {
609655
610656function APIKeyDetails ( {
611657 apiKey,
658+ rotateSecretKey,
612659} : {
660+ rotateSecretKey : ( ) => Promise < RotateSecretKeyAPIReturnType > ;
613661 apiKey : ApiKey ;
614662} ) {
615663 const { createdAt, updatedAt, lastAccessedAt } = apiKey ;
664+ const [ secretKeyMasked , setSecretKeyMasked ] = useState ( apiKey . secretMasked ) ;
616665
617666 return (
618667 < div className = "flex flex-col gap-6 rounded-lg border border-border bg-card px-4 py-6 lg:px-6" >
@@ -632,7 +681,7 @@ function APIKeyDetails({
632681 </ div >
633682
634683 { /* NOTE: for very old api keys the secret might be `null`, if that's the case we skip it */ }
635- { apiKey . secretMasked && (
684+ { secretKeyMasked && (
636685 < div >
637686 < h3 > Secret Key</ h3 >
638687 < p className = "mb-2 text-muted-foreground text-sm" >
@@ -641,8 +690,17 @@ function APIKeyDetails({
641690 the time of creation for the full secret key.
642691 </ p >
643692
644- < div className = "max-w-[350px] rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm" >
645- { apiKey . secretMasked }
693+ < div className = "flex flex-col gap-3 lg:flex-row lg:items-center" >
694+ < div className = "rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm lg:w-[350px]" >
695+ { secretKeyMasked }
696+ </ div >
697+
698+ < RotateSecretKeyButton
699+ rotateSecretKey = { rotateSecretKey }
700+ onSuccess = { ( data ) => {
701+ setSecretKeyMasked ( data . data . secretMasked ) ;
702+ } }
703+ />
646704 </ div >
647705 </ div >
648706 ) }
@@ -726,3 +784,214 @@ function DeleteProject(props: {
726784 />
727785 ) ;
728786}
787+
788+ function RotateSecretKeyButton ( props : {
789+ rotateSecretKey : ( ) => Promise < RotateSecretKeyAPIReturnType > ;
790+ onSuccess : ( data : RotateSecretKeyAPIReturnType ) => void ;
791+ } ) {
792+ const [ isOpen , setIsOpen ] = useState ( false ) ;
793+ const [ isModalCloseAllowed , setIsModalCloseAllowed ] = useState ( true ) ;
794+ return (
795+ < Dialog
796+ open = { isOpen }
797+ onOpenChange = { ( v ) => {
798+ if ( ! isModalCloseAllowed ) {
799+ return ;
800+ }
801+ setIsOpen ( v ) ;
802+ } }
803+ >
804+ < DialogTrigger asChild >
805+ < Button
806+ variant = "outline"
807+ className = "h-auto gap-2 rounded-lg bg-background px-4 py-3"
808+ onClick = { ( ) => setIsOpen ( true ) }
809+ >
810+ < RefreshCcwIcon className = "size-4" />
811+ Rotate Secret Key
812+ </ Button >
813+ </ DialogTrigger >
814+
815+ < DialogContent
816+ className = "overflow-hidden p-0"
817+ dialogCloseClassName = { cn ( ! isModalCloseAllowed && "hidden" ) }
818+ >
819+ < RotateSecretKeyModalContent
820+ rotateSecretKey = { props . rotateSecretKey }
821+ closeModal = { ( ) => {
822+ setIsOpen ( false ) ;
823+ setIsModalCloseAllowed ( true ) ;
824+ } }
825+ disableModalClose = { ( ) => setIsModalCloseAllowed ( false ) }
826+ onSuccess = { props . onSuccess }
827+ />
828+ </ DialogContent >
829+ </ Dialog >
830+ ) ;
831+ }
832+
833+ type RotateSecretKeyScreen =
834+ | { id : "initial" }
835+ | { id : "save-newkey" ; secretKey : string } ;
836+
837+ function RotateSecretKeyModalContent ( props : {
838+ rotateSecretKey : ( ) => Promise < RotateSecretKeyAPIReturnType > ;
839+ closeModal : ( ) => void ;
840+ disableModalClose : ( ) => void ;
841+ onSuccess : ( data : RotateSecretKeyAPIReturnType ) => void ;
842+ } ) {
843+ const [ screen , setScreen ] = useState < RotateSecretKeyScreen > ( {
844+ id : "initial" ,
845+ } ) ;
846+
847+ if ( screen . id === "save-newkey" ) {
848+ return (
849+ < SaveNewKeyScreen
850+ secretKey = { screen . secretKey }
851+ closeModal = { props . closeModal }
852+ />
853+ ) ;
854+ }
855+
856+ if ( screen . id === "initial" ) {
857+ return (
858+ < RotateSecretKeyInitialScreen
859+ rotateSecretKey = { props . rotateSecretKey }
860+ onSuccess = { ( data ) => {
861+ props . disableModalClose ( ) ;
862+ props . onSuccess ( data ) ;
863+ setScreen ( { id : "save-newkey" , secretKey : data . data . secret } ) ;
864+ } }
865+ closeModal = { props . closeModal }
866+ />
867+ ) ;
868+ }
869+
870+ return null ;
871+ }
872+
873+ function RotateSecretKeyInitialScreen ( props : {
874+ rotateSecretKey : ( ) => Promise < RotateSecretKeyAPIReturnType > ;
875+ onSuccess : ( data : RotateSecretKeyAPIReturnType ) => void ;
876+ closeModal : ( ) => void ;
877+ } ) {
878+ const [ isConfirmed , setIsConfirmed ] = useState ( false ) ;
879+ const rotateKeyMutation = useMutation ( {
880+ mutationFn : props . rotateSecretKey ,
881+ onSuccess : ( data ) => {
882+ props . onSuccess ( data ) ;
883+ } ,
884+ onError : ( err ) => {
885+ console . error ( err ) ;
886+ toast . error ( "Failed to rotate secret key" ) ;
887+ } ,
888+ } ) ;
889+ return (
890+ < div >
891+ < div className = "flex flex-col p-6" >
892+ < DialogHeader >
893+ < DialogTitle > Rotate Secret Key</ DialogTitle >
894+ </ DialogHeader >
895+
896+ < div className = "h-6" />
897+
898+ < Alert variant = "destructive" >
899+ < CircleAlertIcon className = "size-5" />
900+ < AlertTitle > Current secret key will stop working</ AlertTitle >
901+ < AlertDescription >
902+ Rotating the secret key will invalidate the current secret key and
903+ generate a new one. This action is irreversible.
904+ </ AlertDescription >
905+ </ Alert >
906+
907+ < div className = "h-4" />
908+
909+ < CheckboxWithLabel className = "text-foreground" >
910+ < Checkbox
911+ checked = { isConfirmed }
912+ onCheckedChange = { ( v ) => setIsConfirmed ( ! ! v ) }
913+ />
914+ I understand the consequences of rotating the secret key
915+ </ CheckboxWithLabel >
916+ </ div >
917+
918+ < div className = "flex justify-end gap-3 border-t bg-card p-6" >
919+ < Button variant = "outline" onClick = { props . closeModal } >
920+ Close
921+ </ Button >
922+ < Button
923+ variant = "destructive"
924+ className = "gap-2"
925+ disabled = { ! isConfirmed || rotateKeyMutation . isPending }
926+ onClick = { ( ) => {
927+ rotateKeyMutation . mutate ( ) ;
928+ } }
929+ >
930+ { rotateKeyMutation . isPending ? (
931+ < Spinner className = "size-4" />
932+ ) : (
933+ < RefreshCcwIcon className = "size-4" />
934+ ) }
935+ Rotate Secret Key
936+ </ Button >
937+ </ div >
938+ </ div >
939+ ) ;
940+ }
941+
942+ function SaveNewKeyScreen ( props : {
943+ secretKey : string ;
944+ closeModal : ( ) => void ;
945+ } ) {
946+ const [ isSecretStored , setIsSecretStored ] = useState ( false ) ;
947+ return (
948+ < div className = "flex min-w-0 flex-col" >
949+ < div className = "flex flex-col p-6" >
950+ < DialogHeader >
951+ < DialogTitle > Save New Secret Key</ DialogTitle >
952+ </ DialogHeader >
953+
954+ < div className = "h-6" />
955+
956+ < CopyTextButton
957+ textToCopy = { props . secretKey }
958+ className = "!h-auto w-full justify-between bg-card px-3 py-3 font-mono"
959+ textToShow = { props . secretKey }
960+ copyIconPosition = "right"
961+ tooltip = "Copy Secret Key"
962+ />
963+ < div className = "h-4" />
964+
965+ < Alert variant = "destructive" >
966+ < AlertTitle > Do not share or expose your secret key</ AlertTitle >
967+ < AlertDescription >
968+ < div className = "mb-5" >
969+ Secret keys cannot be recovered. If you lose your secret key, you
970+ will need to rotate the secret key or create a new Project.
971+ </ div >
972+ < CheckboxWithLabel className = "text-foreground" >
973+ < Checkbox
974+ checked = { isSecretStored }
975+ onCheckedChange = { ( v ) => {
976+ setIsSecretStored ( ! ! v ) ;
977+ } }
978+ />
979+ I confirm that I've securely stored my secret key
980+ </ CheckboxWithLabel >
981+ </ AlertDescription >
982+ </ Alert >
983+ </ div >
984+
985+ < div className = "flex justify-end gap-3 border-t bg-card p-6" >
986+ < Button
987+ variant = "outline"
988+ className = "gap-2"
989+ disabled = { ! isSecretStored }
990+ onClick = { props . closeModal }
991+ >
992+ Close
993+ </ Button >
994+ </ div >
995+ </ div >
996+ ) ;
997+ }
0 commit comments