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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
+8
-1
@@ -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>
|
||||
|
||||
+5
@@ -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>
|
||||
</>
|
||||
|
||||
+2
-25
@@ -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:<port></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'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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user