Skip to content

Commit 8dd0759

Browse files
committed
[TOOL-3251] Dashboard: Add Rotate Secret Key feature in Project Settings page (#6087)
<!-- start pr-codex --> ## 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}` <!-- end pr-codex -->
1 parent 3569763 commit 8dd0759

File tree

5 files changed

+290
-8
lines changed

5 files changed

+290
-8
lines changed

apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.stories.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,16 @@ function Story() {
6060
payConfig: "/payConfig",
6161
}}
6262
onKeyUpdated={undefined}
63+
rotateSecretKey={async () => {
64+
await new Promise((resolve) => setTimeout(resolve, 1000));
65+
return {
66+
data: {
67+
secret: new Array(86).fill("x").join(""),
68+
secretHash: new Array(64).fill("x").join(""),
69+
secretMasked: "123...4567",
70+
},
71+
};
72+
}}
6373
showNebulaSettings={false}
6474
/>
6575

apps/dashboard/src/app/team/[team_slug]/[project_slug]/settings/ProjectGeneralSettingsPage.tsx

Lines changed: 275 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,50 @@
11
"use client";
22

3+
import { apiServerProxy } from "@/actions/proxies";
34
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
45
import { SettingsCard } from "@/components/blocks/SettingsCard";
56
import { CopyTextButton } from "@/components/ui/CopyTextButton";
67
import { DynamicHeight } from "@/components/ui/DynamicHeight";
8+
import { Spinner } from "@/components/ui/Spinner/Spinner";
79
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
810
import { Button } from "@/components/ui/button";
911
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
12+
import {
13+
Dialog,
14+
DialogContent,
15+
DialogHeader,
16+
DialogTitle,
17+
DialogTrigger,
18+
} from "@/components/ui/dialog";
1019
import { Form } from "@/components/ui/form";
1120
import { Input } from "@/components/ui/input";
1221
import { Label } from "@/components/ui/label";
1322
import { Switch } from "@/components/ui/switch";
1423
import { Textarea } from "@/components/ui/textarea";
1524
import { ToolTipLabel } from "@/components/ui/tooltip";
1625
import { useDashboardRouter } from "@/lib/DashboardRouter";
26+
import { cn } from "@/lib/utils";
1727
import type { ApiKey, UpdateKeyInput } from "@3rdweb-sdk/react/hooks/useApi";
1828
import {
1929
useRevokeApiKey,
2030
useUpdateApiKey,
2131
} from "@3rdweb-sdk/react/hooks/useApi";
2232
import { zodResolver } from "@hookform/resolvers/zod";
23-
import type { UseMutationResult } from "@tanstack/react-query";
33+
import { type UseMutationResult, useMutation } from "@tanstack/react-query";
2434
import { SERVICES } from "@thirdweb-dev/service-utils";
2535
import {
2636
type ServiceName,
2737
getServiceByName,
2838
} from "@thirdweb-dev/service-utils";
2939
import { format } from "date-fns";
3040
import { useTrack } from "hooks/analytics/useTrack";
31-
import { ExternalLinkIcon } from "lucide-react";
41+
import {
42+
CircleAlertIcon,
43+
ExternalLinkIcon,
44+
RefreshCcwIcon,
45+
} from "lucide-react";
3246
import Link from "next/link";
47+
import { useState } from "react";
3348
import { type UseFormReturn, useForm } from "react-hook-form";
3449
import { type FieldArrayWithId, useFieldArray } from "react-hook-form";
3550
import { 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+
5073
export 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

89132
type 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

610656
function 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

Comments
 (0)