Skip to content

Commit f5f8a40

Browse files
authored
[TOOL-3251] Dashboard: Add Rotate Secret Key feature in Project Settings page (#6087)
1 parent 3569763 commit f5f8a40

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)