Refactor Create API Key Modal, Add in teams for creating projects (#4583)

## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR adds functionality to create projects in the dashboard UI.

### Detailed summary
- Added `createProject` functionality to various components
- Updated global CSS variables
- Created `LazyCreateAPIKeyDialog` for project creation dialog
- Updated UI components for project creation functionality

> The following files were skipped due to too many changes: `apps/dashboard/src/stories/stubs.ts`, `apps/dashboard/src/pages/dashboard/settings/api-keys/index.tsx`, `apps/dashboard/src/components/settings/ApiKeys/Create/CreateApiKeyModal.stories.tsx`, `apps/dashboard/src/app/team/[team_slug]/(team)/TeamOverviewPage.tsx`, `apps/dashboard/src/components/settings/ApiKeys/Create/index.tsx`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
This commit is contained in:
MananTank
2024-09-13 23:08:24 +00:00
parent ece86ccdc3
commit de362ba2f5
21 changed files with 693 additions and 365 deletions
@@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed grid w-full max-w-lg gap-4 border bg-background p-6 shadow-lg duration-300 rounded-t-xl md:rounded-lg border-border",
"fixed grid w-full md:max-w-lg gap-4 border bg-background p-6 shadow-lg duration-300 rounded-t-xl md:rounded-lg border-border",
// on mobile - put the dialog at the bottom of the screen, animate - slide up and fade in
"bottom-0 left-0 right-0",
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+2 -1
View File
@@ -27,7 +27,7 @@
--muted-foreground: 0 0% 40%;
--accent-foreground: 0 0% 9%;
--destructive-foreground: 0 0% 100%;
--inverted-foreground: var(--background);
--inverted-foreground: 0 0% 100%;
--link-foreground: 221.21deg 83.19% 53.33%;
--success-text: 142.09 70.56% 35.29%;
--warning-text: 38 92% 40%;
@@ -72,6 +72,7 @@
--warning-text: 38 92% 50%;
--destructive-text: 360 72% 55%;
--success-text: 142 75% 50%;
--inverted-foreground: 0 0% 0%;
/* Borders */
--border: 0 0% 15%;
@@ -172,7 +172,7 @@ interface UpdateKeyServiceInput {
actions?: string[];
}
interface CreateKeyInput {
export interface CreateKeyInput {
name?: string;
domains?: string[];
bundleIds?: string[];
@@ -5,7 +5,8 @@ import type { Team } from "@/api/team";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { LazyCreateAPIKeyDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import {
type AccountHeaderCompProps,
AccountHeaderDesktopUI,
@@ -17,6 +18,8 @@ export function AccountHeader(props: {
}) {
const myAccountQuery = useAccount();
const router = useDashboardRouter();
const [isCreateProjectDialogOpen, setIsCreateProjectDialogOpen] =
useState(false);
const logout = useCallback(async () => {
// log out the user
@@ -35,12 +38,23 @@ export function AccountHeader(props: {
email: myAccountQuery.data?.email,
logout: logout,
connectButton: <CustomConnectWallet />,
createProject: () => setIsCreateProjectDialogOpen(true),
};
return (
<div>
<AccountHeaderDesktopUI {...headerProps} className="max-lg:hidden" />
<AccountHeaderMobileUI {...headerProps} className="lg:hidden" />
<LazyCreateAPIKeyDialog
open={isCreateProjectDialogOpen}
onOpenChange={setIsCreateProjectDialogOpen}
wording="project"
onCreateAndComplete={() => {
// refresh projects
router.refresh();
}}
/>
</div>
);
}
@@ -53,6 +53,7 @@ function Variants(props: {
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButtonStub />}
createProject={() => {}}
/>
</BadgeContainer>
</div>
@@ -14,6 +14,7 @@ export type AccountHeaderCompProps = {
logout: () => void;
connectButton: React.ReactNode;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
createProject: () => void;
};
export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
@@ -46,6 +47,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
currentTeam={undefined}
teamsAndProjects={props.teamsAndProjects}
focus="team-selection"
createProject={props.createProject}
/>
</div>
</div>
@@ -2,18 +2,16 @@
import type { Project } from "@/api/projects";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { format } from "date-fns";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { format } from "date-fns";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { CopyButton } from "@/components/ui/CopyButton";
import {
@@ -22,6 +20,8 @@ import {
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { LazyCreateAPIKeyDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
type SortyById = "name" | "createdAt";
@@ -32,6 +32,9 @@ export function TeamOverviewPage(props: {
const { projects } = props;
const [searchTerm, setSearchTerm] = useState("");
const [sortBy, setSortBy] = useState<SortyById>("createdAt");
const [isCreateProjectDialogOpen, setIsCreateProjectDialogOpen] =
useState(false);
const router = useDashboardRouter();
let projectsToShow = !searchTerm
? projects
@@ -63,7 +66,10 @@ export function TeamOverviewPage(props: {
<SearchInput value={searchTerm} onValueChange={setSearchTerm} />
<div className="flex gap-4">
<SelectBy value={sortBy} onChange={setSortBy} />
<AddNewButton />
<AddNewButton
createProject={() => setIsCreateProjectDialogOpen(true)}
teamMembersSettingsPath={`/team/${props.team_slug}/~/settings/members`}
/>
</div>
</div>
@@ -89,6 +95,16 @@ export function TeamOverviewPage(props: {
)}
<div className="h-10" />
<LazyCreateAPIKeyDialog
open={isCreateProjectDialogOpen}
onOpenChange={setIsCreateProjectDialogOpen}
wording="project"
onCreateAndComplete={() => {
// refresh projects
router.refresh();
}}
/>
</div>
);
}
@@ -153,7 +169,10 @@ function SearchInput(props: {
);
}
function AddNewButton() {
function AddNewButton(props: {
createProject: () => void;
teamMembersSettingsPath: string;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -164,15 +183,15 @@ function AddNewButton() {
</DropdownMenuTrigger>
<DropdownMenuContent className="p-2 min-w-[220px]" sideOffset={12}>
<div className="flex flex-col gap-1">
{/* TODO - remove soon when we have it */}
<Button variant="ghost" className="justify-between px-2" disabled>
<Button
variant="ghost"
className="justify-between px-2"
onClick={props.createProject}
>
Project
<Badge variant="outline"> Soon</Badge>
</Button>
{/* TODO - remove soon when we have it */}
<Button variant="ghost" className="justify-between px-2" disabled>
Team Member
<Badge variant="outline"> Soon</Badge>
<Button variant="ghost" className="justify-between px-2" asChild>
<Link href={props.teamMembersSettingsPath}>Team Member</Link>
</Button>
</div>
</DropdownMenuContent>
@@ -6,19 +6,22 @@ import { DynamicHeight } from "@/components/ui/DynamicHeight";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { ChevronsUpDownIcon } from "lucide-react";
import { useState } from "react";
import { ProjectSelectorUI } from "./ProjectSelectorUI";
type ProjectSelectorMobileMenuButtonProps = {
currentProject: Project;
projects: Project[];
team: Team;
createProject: () => void;
};
export function ProjectSelectorMobileMenuButton(
props: ProjectSelectorMobileMenuButtonProps,
) {
const [open, setOpen] = useState(false);
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
{/* Trigger */}
<DialogTrigger asChild>
<Button
@@ -37,6 +40,10 @@ export function ProjectSelectorMobileMenuButton(
currentProject={props.currentProject}
projects={props.projects}
team={props.team}
createProject={() => {
props.createProject();
setOpen(false);
}}
/>
</DynamicHeight>
</DialogContent>
@@ -1,7 +1,8 @@
"use client";
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
@@ -14,6 +15,7 @@ export function ProjectSelectorUI(props: {
projects: Project[];
currentProject: Project | undefined;
team: Team;
createProject: () => void;
}) {
const { projects, currentProject, team } = props;
const [searchProjectTerm, setSearchProjectTerm] = useState("");
@@ -76,13 +78,10 @@ export function ProjectSelectorUI(props: {
<Button
className="px-2 w-full gap-2 justify-start disabled:opacity-100 disabled:pointer-events-auto disabled:cursor-not-allowed"
variant="ghost"
disabled
onClick={props.createProject}
>
<CirclePlusIcon className="size-4 text-link-foreground" />
Create Project
<Badge className="ml-auto" variant="secondary">
Soon{"™️"}
</Badge>
</Button>
</div>
</div>
@@ -19,6 +19,7 @@ type TeamSwitcherProps = {
currentProject: Project | undefined;
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
focus: "project-selection" | "team-selection";
createProject: () => void;
};
export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
@@ -80,6 +81,10 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
currentProject={props.currentProject}
projects={projectsToShow}
team={projectsToShowOfTeam}
createProject={() => {
setOpen(false);
props.createProject();
}}
/>
)}
</div>
@@ -5,8 +5,9 @@ import type { Team } from "@/api/team";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { useActiveWallet, useDisconnect } from "thirdweb/react";
import { LazyCreateAPIKeyDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
import {
type TeamHeaderCompProps,
TeamHeaderDesktopUI,
@@ -18,6 +19,8 @@ export function TeamHeader(props: {
teamsAndProjects: Array<{ team: Team; projects: Project[] }>;
currentProject: Project | undefined;
}) {
const [isCreateProjectDialogOpen, setIsCreateProjectDialogOpen] =
useState(false);
const myAccountQuery = useAccount();
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
@@ -45,12 +48,23 @@ export function TeamHeader(props: {
email: myAccountQuery.data?.email,
logout: logout,
connectButton: <CustomConnectWallet />,
createProject: () => setIsCreateProjectDialogOpen(true),
};
return (
<div>
<TeamHeaderDesktopUI {...headerProps} className="max-lg:hidden" />
<TeamHeaderMobileUI {...headerProps} className="lg:hidden" />
<LazyCreateAPIKeyDialog
open={isCreateProjectDialogOpen}
onOpenChange={setIsCreateProjectDialogOpen}
wording="project"
onCreateAndComplete={() => {
// refresh projects
router.refresh();
}}
/>
</div>
);
}
@@ -52,6 +52,7 @@ function Variants(props: {
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButtonStub />}
createProject={() => {}}
/>
</BadgeContainer>
@@ -63,6 +64,7 @@ function Variants(props: {
email={undefined}
logout={() => {}}
connectButton={<ConnectButtonStub />}
createProject={() => {}}
/>
</BadgeContainer>
@@ -74,6 +76,7 @@ function Variants(props: {
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButtonStub />}
createProject={() => {}}
/>
</BadgeContainer>
@@ -85,6 +88,7 @@ function Variants(props: {
email="[email protected]"
logout={() => {}}
connectButton={<ConnectButtonStub />}
createProject={() => {}}
/>
</BadgeContainer>
</div>
@@ -19,6 +19,7 @@ export type TeamHeaderCompProps = {
email: string | undefined;
logout: () => void;
connectButton: React.ReactNode;
createProject: () => void;
};
export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
@@ -56,6 +57,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
currentTeam={props.currentTeam}
teamsAndProjects={props.teamsAndProjects}
focus="team-selection"
createProject={props.createProject}
/>
</div>
@@ -75,6 +77,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
currentTeam={props.currentTeam}
teamsAndProjects={props.teamsAndProjects}
focus="project-selection"
createProject={props.createProject}
/>
</div>
</>
@@ -144,6 +147,7 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
currentProject={props.currentProject}
projects={projects}
team={props.currentTeam}
createProject={props.createProject}
/>
</div>
</>
@@ -1,5 +1,5 @@
import type { ApiKey, ApiKeyService } from "@3rdweb-sdk/react/hooks/useApi";
import type { Meta, StoryObj } from "@storybook/react";
import { createApiKeyStub } from "../../../stories/stubs";
import { mobileViewport } from "../../../stories/utils";
import { InAppWalletSettingsUI } from "./index";
@@ -46,30 +46,7 @@ export const FreePlanMobile: Story = {
},
};
const embeddedWalletService: ApiKeyService = {
id: "embeddedWallets",
name: "embeddedWallets", // important
targetAddresses: [],
actions: [],
};
const apiKeyStub: ApiKey = {
id: "api-key-id-foo",
name: "api key name foo",
key: "key-foobar",
accountId: "account-id-foo",
bundleIds: ["bundle-id-foo", "bundle-id-bar"],
createdAt: new Date().toISOString(),
creatorWalletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
domains: ["example1.com", "example2.com"],
secretMasked: "",
walletAddresses: ["0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37"],
redirectUrls: [],
revokedAt: "",
lastAccessedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
services: [embeddedWalletService],
};
const apiKeyStub = createApiKeyStub();
function Variants(props: {
canEditAdvancedFeatures: boolean;
@@ -0,0 +1,89 @@
import { Button } from "@/components/ui/button";
import type { CreateKeyInput } from "@3rdweb-sdk/react/hooks/useApi";
import type { Meta, StoryObj } from "@storybook/react";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Toaster } from "sonner";
import { CreateAPIKeyDialogUI } from ".";
import { createApiKeyStub } from "../../../../stories/stubs";
import { mobileViewport } from "../../../../stories/utils";
const meta = {
title: "Project/Create Project Modal",
component: Story,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Story>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DesktopProjectWording: Story = {
args: {
wording: "project",
},
};
export const MobileProjectWording: Story = {
args: {
wording: "project",
},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
export const DesktopAPIKeyWording: Story = {
args: {
wording: "api-key",
},
};
export const MobileAPIKeyWording: Story = {
args: {
wording: "api-key",
},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Story(props: {
wording: "project" | "api-key";
}) {
const [isOpen, setIsOpen] = useState(true);
const mutation = useMutation({
mutationFn: async (input: CreateKeyInput) => {
console.log("Creating API key with", input);
await new Promise((resolve) => setTimeout(resolve, 1000));
const apiKey = createApiKeyStub();
apiKey.name = input.name || apiKey.name;
return apiKey;
},
});
return (
<div className="p-6 flex justify-center items-center grow">
<CreateAPIKeyDialogUI
wording={props.wording}
open={isOpen}
onOpenChange={setIsOpen}
createKeyMutation={mutation}
/>
<Button
variant="outline"
onClick={() => {
setIsOpen(true);
}}
>
Open
</Button>
<Toaster richColors />
</div>
);
}
@@ -1,105 +0,0 @@
import {
Flex,
FormControl,
HStack,
Input,
ListItem,
Textarea,
UnorderedList,
} from "@chakra-ui/react";
import type { UseFormReturn } from "react-hook-form";
import {
Checkbox,
FormErrorMessage,
FormHelperText,
FormLabel,
Text,
} from "tw-components";
import type { ApiKeyCreateValidationSchema } from "../validations";
interface CreateGeneralProps {
// biome-ignore lint/suspicious/noExplicitAny: FIXME
form: UseFormReturn<ApiKeyCreateValidationSchema, any>;
}
export const CreateGeneral: React.FC<CreateGeneralProps> = ({ form }) => {
return (
<Flex flexDir="column" gap={6}>
<FormControl
isRequired
isInvalid={!!form.getFieldState("name", form.formState).error}
>
<FormLabel>Key name</FormLabel>
<Input
autoFocus
placeholder="API Key name"
type="text"
{...form.register("name")}
/>
<FormErrorMessage>
{form.getFieldState("name", form.formState).error?.message}
</FormErrorMessage>
</FormControl>
<Flex flexDir="column" gap={4}>
<FormControl
isInvalid={!!form.getFieldState("domains", form.formState).error}
>
<FormHelperText m={0} pb={6} size="body.md">
Prevent third-parties from using your Client ID by restricting
access to allowed domains.{" "}
<Text size="label.md" as="span">
Highly recommended
</Text>{" "}
for frontend applications.
</FormHelperText>
<HStack alignItems="center" justifyContent="space-between" pb={2}>
<FormLabel size="label.sm" mb={0}>
Allowed Domains
</FormLabel>
<Checkbox
isChecked={form.watch("domains") === "*"}
onChange={(e) => {
form.setValue("domains", e.target.checked ? "*" : "", {
shouldDirty: true,
});
}}
>
<Text>Allow all domains</Text>
</Checkbox>
</HStack>
<Textarea
placeholder="thirdweb.com, rpc.example.com, localhost:3000"
{...form.register("domains")}
/>
{!form.getFieldState("domains", form.formState).error ? (
<FormHelperText>
<UnorderedList pt={2} spacing={1}>
<Text as={ListItem}>
Authorize all domains with <code>*</code>
<br />
<Text as="span" pl={2}>
f.ex: <code>*.thirdweb.com</code> accepts all{" "}
<code>.thirdweb.com</code> sites
</Text>
</Text>
<Text as={ListItem}>
Authorize local URLs with <code>localhost:&lt;port&gt;</code>
</Text>
<Text as={ListItem}>
Separate domains by commas or new lines
</Text>
</UnorderedList>
</FormHelperText>
) : (
<FormErrorMessage>
{form.getFieldState("domains", form.formState).error?.message}
</FormErrorMessage>
)}
</FormControl>
</Flex>
</Flex>
);
};
@@ -1,51 +0,0 @@
import type { ApiKey } from "@3rdweb-sdk/react/hooks/useApi";
import { Divider, Flex, Spinner } from "@chakra-ui/react";
import { CodeBlock, Text } from "tw-components";
import { FieldAlert } from "../Alerts";
import { DetailsRow } from "../DetailsRow";
interface CreateKeysProps {
isLoading: boolean;
apiKey: ApiKey;
}
export const CreateKeys: React.FC<CreateKeysProps> = ({
apiKey,
isLoading,
}) => {
return (
<Flex flexDir="column" gap={4}>
<DetailsRow
title="Client ID"
content={
isLoading ? (
<Spinner size="sm" />
) : apiKey?.key ? (
<CodeBlock codeValue={apiKey.key} code={apiKey.key} />
) : (
<Text>Error generating keys</Text>
)
}
description="Identifies your application."
/>
<Divider />
<DetailsRow
title="Secret Key"
content={
isLoading ? (
<Spinner size="sm" />
) : apiKey?.secret ? (
<CodeBlock codeValue={apiKey.secret} code={apiKey.secret} />
) : (
<Text>Error generating keys</Text>
)
}
description="Identifies and authenticates your application from a backend."
/>
<FieldAlert message="SecretKey" />
</Flex>
);
};
@@ -0,0 +1,29 @@
"use client";
import { Suspense, lazy, useEffect, useState } from "react";
import type { CreateAPIKeyDialogProps } from "./index";
const CreateAPIKeyDialog = lazy(() => import("./index"));
export function LazyCreateAPIKeyDialog(props: CreateAPIKeyDialogProps) {
// if we use props.open to conditionally render the lazy component, - the dialog will close suddenly when the user closes it instead of gracefully fading out
// and we can't render the dialog unconitionally because it will be rendered on the first page load and that defeats the purpose of lazy loading
const [hasEverOpened, setHasEverOpened] = useState(false);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (props.open) {
setHasEverOpened(true);
}
}, [props.open]);
if (hasEverOpened) {
return (
<Suspense fallback={null}>
<CreateAPIKeyDialog {...props} />
</Suspense>
);
}
return null;
}
@@ -1,47 +1,141 @@
import { type ApiKey, useCreateApiKey } from "@3rdweb-sdk/react/hooks/useApi";
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 {
Flex,
HStack,
Icon,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useDisclosure,
} from "@chakra-ui/react";
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
type ApiKey,
type CreateKeyInput,
useCreateApiKey,
} from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { DialogDescription } from "@radix-ui/react-dialog";
import type { UseMutationResult } from "@tanstack/react-query";
import { SERVICES } from "@thirdweb-dev/service-utils";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { ArrowLeftIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { FiPlus } from "react-icons/fi";
import { Button, type ButtonProps, Checkbox, Text } from "tw-components";
import { toast } from "sonner";
import { toArrFromList } from "utils/string";
import Message from "../message";
import {
type ApiKeyCreateValidationSchema,
apiKeyCreateValidationSchema,
} from "../validations";
import { CreateGeneral } from "./General";
import { CreateKeys } from "./Keys";
interface CreateAPIKeyButtonProps {
buttonProps?: ButtonProps;
}
export type CreateAPIKeyDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
wording: "project" | "api-key";
onCreateAndComplete?: () => void;
};
export const CreateApiKeyButton: React.FC<CreateAPIKeyButtonProps> = ({
buttonProps,
}) => {
const [step, setStep] = useState<"create" | "domainsAlert" | "keys">(
"create",
export const CreateAPIKeyDialog = (props: CreateAPIKeyDialogProps) => {
const createKeyMutation = useCreateApiKey();
return (
<CreateAPIKeyDialogUI createKeyMutation={createKeyMutation} {...props} />
);
const [apiKey, setApiKey] = useState<ApiKey | null>(null);
const [secretStored, setSecretStored] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
};
export default CreateAPIKeyDialog;
export const CreateAPIKeyDialogUI = (props: {
open: boolean;
onOpenChange: (open: boolean) => void;
wording: "project" | "api-key";
onCreateAndComplete?: () => void;
createKeyMutation: UseMutationResult<
ApiKey,
unknown,
CreateKeyInput,
unknown
>;
}) => {
const [screen, setScreen] = useState<
{ id: "create" } | { id: "api-details"; key: ApiKey }
>({ id: "create" });
const { open, onOpenChange, createKeyMutation } = props;
return (
<Dialog
open={open}
onOpenChange={(v) => {
// Prevent closing the dialog when the API key is created - to make sure user does not accidentally close the dialog without copying the secret key
if (screen.id === "api-details") {
return;
}
onOpenChange(v);
}}
>
<DialogContent
className="p-0 z-[10001]"
dialogOverlayClassName="z-[10000]"
dialogCloseClassName={screen.id === "api-details" ? "hidden" : ""}
>
<DynamicHeight>
{screen.id === "create" && (
<CreateAPIKeyForm
wording={props.wording}
createKeyMutation={createKeyMutation}
onAPIKeyCreated={(key) => {
setScreen({ id: "api-details", key });
}}
/>
)}
{screen.id === "api-details" && (
<APIKeyDetails
apiKey={screen.key}
onComplete={() => {
onOpenChange(false);
setScreen({ id: "create" });
props.onCreateAndComplete?.();
}}
wording={props.wording}
/>
)}
</DynamicHeight>
</DialogContent>
</Dialog>
);
};
function CreateAPIKeyForm(props: {
wording: "project" | "api-key";
createKeyMutation: UseMutationResult<
ApiKey,
unknown,
CreateKeyInput,
unknown
>;
onAPIKeyCreated: (key: ApiKey) => void;
}) {
const { wording } = props;
const [showAlert, setShowAlert] = useState<"no-domain" | "any-domain">();
const { createKeyMutation } = props;
const trackEvent = useTrack();
const form = useForm<ApiKeyCreateValidationSchema>({
@@ -52,16 +146,10 @@ export const CreateApiKeyButton: React.FC<CreateAPIKeyButtonProps> = ({
},
});
const noDomains = step === "domainsAlert" && !form.watch("domains");
const anyDomain = step === "domainsAlert" && form.watch("domains") === "*";
const createKeyMutation = useCreateApiKey();
const { onSuccess, onError } = useTxNotifications(
"API key created",
"Failed to create API key",
);
const handleSubmit = form.handleSubmit((values) => {
function handleAPICreation(values: {
name: string;
domains: string;
}) {
const formattedValues = {
name: values.name,
domains: toArrFromList(values.domains),
@@ -85,10 +173,10 @@ export const CreateApiKeyButton: React.FC<CreateAPIKeyButtonProps> = ({
createKeyMutation.mutate(formattedValues, {
onSuccess: (data) => {
onSuccess();
setApiKey(data);
setStep("keys");
toast.success(
`${wording === "api-key" ? "API Key" : "Project"} created successfully`,
);
props.onAPIKeyCreated(data);
trackEvent({
category: "api-keys",
action: "create",
@@ -96,7 +184,9 @@ export const CreateApiKeyButton: React.FC<CreateAPIKeyButtonProps> = ({
});
},
onError: (err) => {
onError(err);
toast.error(
`Failed to create ${wording === "api-key" ? "API Key" : "Project"}`,
);
trackEvent({
category: "api-keys",
action: "create",
@@ -105,120 +195,293 @@ export const CreateApiKeyButton: React.FC<CreateAPIKeyButtonProps> = ({
});
},
});
}
const handleSubmit = form.handleSubmit((values) => {
if (!values.domains) {
setShowAlert("no-domain");
} else if (values.domains === "*") {
setShowAlert("any-domain");
} else {
handleAPICreation({
name: values.name,
domains: values.domains,
});
}
});
const handleClose = () => {
if (step !== "create") {
return;
}
onClose();
form.reset();
};
const handleNext = async () => {
if (step === "create") {
await form.trigger();
if (form.formState.isValid) {
const domains = form.getValues("domains");
if (!domains || domains === "*") {
setStep("domainsAlert");
} else {
handleSubmit();
}
}
} else if (step === "domainsAlert") {
handleSubmit();
} else {
setApiKey(null);
form.reset();
onClose();
setStep("create");
}
};
if (showAlert) {
return (
<DomainsAlert
type={showAlert}
isCreating={props.createKeyMutation.isLoading}
onProceed={() => {
handleAPICreation({
name: form.getValues("name"),
domains: form.getValues("domains"),
});
}}
onGoBack={() => setShowAlert(undefined)}
/>
);
}
return (
<>
<Modal isOpen={isOpen} onClose={handleClose} size="lg" isCentered>
<ModalOverlay />
<ModalContent className="!bg-background border border-border rounded-lg">
<form onSubmit={handleSubmit} autoComplete="off">
<ModalHeader>
{step === "create" && "Create an API Key"}
{noDomains && Message.NoDomainsTitle}
{anyDomain && Message.AnyDomainTitle}
{step === "keys" && apiKey?.name}
</ModalHeader>
<Form {...form}>
<form onSubmit={handleSubmit} autoComplete="off">
<div className="p-6">
<DialogHeader className="mb-4">
<DialogTitle className="text-2xl">
{props.wording === "api-key"
? "Create an API Key"
: "Create a Project"}
</DialogTitle>
</DialogHeader>
{step === "create" && <ModalCloseButton />}
<ModalBody>
{step === "create" && <CreateGeneral form={form} />}
{noDomains && <Text>{Message.NoDomainsDescription}</Text>}
{anyDomain && <Text>{Message.AnyDomainDescription}</Text>}
{step === "keys" && (
<CreateKeys
apiKey={apiKey as ApiKey}
isLoading={createKeyMutation.isLoading}
/>
<div className="flex flex-col gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{wording === "api-key" ? "Display Name" : "Project Name"}
</FormLabel>
<FormControl>
<Input
className="bg-muted/50"
placeholder={
wording === "api-key" ? "My API Key" : "My Project"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
</ModalBody>
<ModalFooter gap={4}>
<Flex flexDir="column" gap={4} w="full">
{step === "keys" && (
<HStack>
<Checkbox
isChecked={secretStored}
onChange={(e) => setSecretStored(e.target.checked)}
>
<Text>
I confirm that I&apos;ve securely stored my secret key
</Text>
</Checkbox>
</HStack>
)}
<HStack
justifyContent={
step === "domainsAlert" ? "space-between" : "flex-end"
}
w="full"
>
{step === "domainsAlert" && (
<Button variant="outline" onClick={() => setStep("create")}>
Back
</Button>
)}
<Button
colorScheme="blue"
type="button"
onClick={handleNext}
isLoading={createKeyMutation.isLoading}
isDisabled={
createKeyMutation.isLoading ||
(step === "keys" && !secretStored)
}
>
{step === "domainsAlert"
? "Proceed"
: step === "create"
? "Next"
: "Complete"}
</Button>
</HStack>
</Flex>
</ModalFooter>
</form>
</ModalContent>
</Modal>
/>
<Button
{...buttonProps}
onClick={onOpen}
colorScheme="blue"
leftIcon={<Icon as={FiPlus} boxSize={4} />}
>
Create API Key
</Button>
</>
<div className="relative">
<CheckboxWithLabel className="absolute top-0 right-0 text-foreground">
<Checkbox
checked={form.watch("domains") === "*"}
onCheckedChange={(checked) => {
form.setValue("domains", checked ? "*" : "", {
shouldDirty: true,
});
}}
/>
Allow all domains
</CheckboxWithLabel>
<FormField
control={form.control}
name="domains"
render={({ field }) => (
<FormItem>
<FormLabel>Allowed Domains</FormLabel>
<FormControl>
<Textarea
className="bg-muted/50"
placeholder="thirdweb.com, *.example.com, localhost:3000"
{...field}
/>
</FormControl>
<FormMessage />
<div className="pt-1">
<FormDescription className="mb-2 leading-relaxed text-sm">
Prevent third-parties from using your Client ID by
restricting access to allowed domains. Highly
recommended for frontend applications.
</FormDescription>
<ul className="list-disc text-sm text-muted-foreground pl-3 [&>li]:pl-1 flex flex-col gap-2 ">
<li>
Authorize all domains with{" "}
<span className="bg-muted text-xs font-mono inline-block px-2 rounded">
*
</span>
<br />
<span>
Example:{" "}
<span className="bg-muted text-xs font-mono inline-block px-2 rounded">
*.thirdweb.com
</span>{" "}
accepts all{" "}
<span className="bg-muted text-xs font-mono inline-block px-2 rounded">
.thirdweb.com
</span>{" "}
sites
</span>
</li>
<li>
Authorize local URLs with{" "}
<span className="bg-muted text-xs font-mono inline-block px-2 rounded">
{"localhost:<port>"}
</span>
</li>
<li>Separate domains by commas or new lines</li>
</ul>
</div>
</FormItem>
)}
/>
</div>
</div>
</div>
<DialogFooter className="border-t border-border p-6 bg-muted/50 flex gap-4 md:gap-2">
<DialogClose asChild>
<Button variant="outline" className="gap-2 min-w-28">
Cancel
</Button>
</DialogClose>
<Button
type="submit"
disabled={props.createKeyMutation.isLoading}
className="gap-2 min-w-28"
>
{props.createKeyMutation.isLoading && (
<Spinner className="size-4" />
)}
Create
</Button>
</DialogFooter>
</form>
</Form>
);
};
}
function DomainsAlert(props: {
type: "no-domain" | "any-domain";
isCreating: boolean;
onProceed: () => void;
onGoBack: () => void;
}) {
return (
<div>
<div className="p-6 mb-4">
<DialogHeader>
<DialogTitle className="text-2xl">
{props.type === "no-domain" && "No Domains Configured"}
{props.type === "any-domain" && "Unrestricted Web Access"}
</DialogTitle>
<DialogDescription>
{props.type === "no-domain" &&
"This will deny requests from all origins, rendering the key unusable in frontend applications. Proceed only if you intend to use this key in server or native apps environments."}
{props.type === "any-domain" &&
"Requests from all origins will be authorized. Your key can be used by any website without restriction"}
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter className="border-t border-border p-6 bg-muted/50 flex !justify-between gap-4">
<Button
variant="outline"
onClick={props.onGoBack}
className="gap-2 min-w-28"
>
<ArrowLeftIcon className="size-4" />
Update Domains
</Button>
<Button
type="button"
onClick={props.onProceed}
disabled={props.isCreating}
className="gap-2 min-w-28"
variant="destructive"
>
{props.isCreating && <Spinner className="size-4" />}
Proceed
</Button>
</DialogFooter>
</div>
);
}
function APIKeyDetails(props: {
apiKey: ApiKey;
onComplete: () => void;
wording: "project" | "api-key";
}) {
const { apiKey } = props;
const [secretStored, setSecretStored] = useState(false);
return (
<div>
<div className="p-6">
<DialogHeader>
<DialogTitle className="text-2xl">{props.apiKey.name}</DialogTitle>
</DialogHeader>
<div className="h-3" />
<section>
<h3>Client ID</h3>
<p className="text-muted-foreground text-sm mb-2">
Identifies your application.
</p>
<CopyTextButton
textToCopy={apiKey.key}
className="truncate w-full justify-between font-mono bg-muted/50 px-3 !h-auto py-3"
textToShow={apiKey.key}
copyIconPosition="right"
tooltip="Copy Client ID"
/>
</section>
<div className="h-6" />
<section>
<h3>Secret Key</h3>
<p className="text-muted-foreground text-sm mb-2">
Identifies and authenticates your application from a backend.
</p>
<CopyTextButton
textToCopy={apiKey.secret || ""}
className="truncate w-full justify-between font-mono bg-muted/50 px-3 !h-auto py-3"
textToShow={apiKey.secret || ""}
copyIconPosition="right"
tooltip="Copy Secret Key"
/>
</section>
<div className="h-3" />
<Alert variant="destructive">
<AlertTitle>Do not share or expose your secret key</AlertTitle>
<AlertDescription>
<div className="mb-5">
Secret keys cannot be recovered. If you lose your secret key, you
will need to create a{" "}
{props.wording === "project" ? "project" : "API key"}
</div>
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={secretStored}
onCheckedChange={(v) => {
setSecretStored(!!v);
}}
/>
I confirm that I've securely stored my secret key
</CheckboxWithLabel>
</AlertDescription>
</Alert>
</div>
<DialogFooter className="border-t border-border p-6 bg-muted/50 flex">
<Button
type="button"
onClick={props.onComplete}
disabled={!secretStored}
className="gap-2 min-w-28"
>
Complete
</Button>
</DialogFooter>
</div>
);
}
@@ -1,3 +1,4 @@
import { Button } from "@/components/ui/button";
import {
AccountStatus,
useAccount,
@@ -7,16 +8,18 @@ import { Flex } from "@chakra-ui/react";
import { AppLayout } from "components/app-layouts/app";
import { ApiKeys } from "components/settings/ApiKeys";
import { SmartWalletsBillingAlert } from "components/settings/ApiKeys/Alerts";
import { CreateApiKeyButton } from "components/settings/ApiKeys/Create";
import { SettingsSidebarLayout } from "core-ui/sidebar/settings";
import { PlusIcon } from "lucide-react";
import { PageId } from "page-id";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Link } from "tw-components";
import type { ThirdwebNextPage } from "utils/types";
import { LazyCreateAPIKeyDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
const SettingsApiKeysPage: ThirdwebNextPage = () => {
const keysQuery = useApiKeys();
const meQuery = useAccount();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const account = meQuery?.data;
const apiKeys = keysQuery?.data;
@@ -36,6 +39,12 @@ const SettingsApiKeysPage: ThirdwebNextPage = () => {
return (
<Flex flexDir="column" gap={8}>
<LazyCreateAPIKeyDialog
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
wording="api-key"
/>
<Flex direction="column" gap={2}>
<Flex
justifyContent="space-between"
@@ -43,7 +52,10 @@ const SettingsApiKeysPage: ThirdwebNextPage = () => {
gap={4}
>
<h1 className="text-3xl font-semibold tracking-tight">API Keys</h1>
<CreateApiKeyButton />
<Button onClick={() => setIsCreateModalOpen(true)} className="gap-2">
<PlusIcon className="size-4" />
Create API Key
</Button>
</Flex>
<p className="text-muted-foreground text-sm">
+44
View File
@@ -1,5 +1,6 @@
import type { Project } from "@/api/projects";
import type { Team } from "@/api/team";
import type { ApiKey, ApiKeyService } from "@3rdweb-sdk/react/hooks/useApi";
export function projectStub(id: string, teamId: string) {
const project: Project = {
@@ -60,3 +61,46 @@ export const teamsAndProjectsStub: Array<{ team: Team; projects: Project[] }> =
projects: [projectStub("t3p1", "team-3")],
},
];
function generateRandomString(length: number) {
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
result += characters.charAt(randomIndex);
}
return result;
}
export function createApiKeyStub() {
const embeddedWalletService: ApiKeyService = {
id: "embeddedWallets",
name: "embeddedWallets", // important
targetAddresses: [],
actions: [],
};
const apiKeyStub: ApiKey = {
id: "api-key-id-foo",
name: "api key name foo",
key: generateRandomString(31),
accountId: "account-id-foo",
bundleIds: ["bundle-id-foo", "bundle-id-bar"],
createdAt: new Date().toISOString(),
creatorWalletAddress: "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37",
domains: ["example1.com", "example2.com"],
secretMasked: "",
walletAddresses: ["0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37"],
redirectUrls: [],
revokedAt: "",
lastAccessedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
services: [embeddedWalletService],
secret: generateRandomString(86),
};
return apiKeyStub;
}