Add Account Settings page (#4558)

## Problem solved

Short description of the bug fixed or feature added

<!-- start pr-codex -->

---

## PR-Codex overview
This PR updates account and team-related UI components and settings pages.

### Detailed summary
- Updated titles in account and team UI components
- Added confirmation dialog to setting cards
- Improved layout in account and team pages
- Implemented account authentication logic

> The following files were skipped due to too many changes: `apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.tsx`, `apps/dashboard/src/app/account/settings/AccountSettingsPageUI.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-12 16:34:41 +00:00
parent f092b2aeac
commit 5385f6bde1
16 changed files with 567 additions and 55 deletions
@@ -1,3 +1,4 @@
import { cn } from "../../lib/utils";
import { MobileSidebar } from "./MobileSidebar";
import { Sidebar, type SidebarLink } from "./Sidebar";
@@ -6,10 +7,16 @@ export function SidebarLayout(props: {
children: React.ReactNode;
desktopSidebarClassName?: string;
mobileSidebarClassName?: string;
className?: string;
}) {
const { sidebarLinks, children } = props;
return (
<div className="flex flex-col lg:flex-row gap-6 container px-4 w-full grow max-sm:pt-6">
<div
className={cn(
"flex flex-col lg:flex-row gap-6 container px-4 w-full grow max-sm:pt-6",
props.className,
)}
>
<Sidebar links={sidebarLinks} className={props.desktopSidebarClassName} />
<MobileSidebar
links={sidebarLinks}
@@ -13,7 +13,15 @@ export default async function AccountLayout(props: {
<div className="min-h-full flex flex-col bg-background">
<div className="grow flex flex-col">
<HeaderAndNav />
<div className="py-10 border-b border-border">
<div className="max-w-[1100px] mx-auto px-6">
<h1 className="text-3xl tracking-tight font-semibold">
My Account
</h1>
</div>
</div>
<SidebarLayout
className="max-w-[1100px]"
sidebarLinks={[
{
href: "/account",
@@ -4,7 +4,7 @@ import { BadgeContainer, mobileViewport } from "../../../stories/utils";
import { AccountTeamsUI } from "./AccountTeamsUI";
const meta = {
title: "Account/Teams",
title: "Account/Pages/Teams",
component: Variants,
parameters: {
nextjs: {
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Toaster } from "sonner";
import { ThirdwebProvider } from "thirdweb/react";
import { mobileViewport } from "../../../stories/utils";
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
const meta = {
title: "Account/Pages/Settings",
component: Variants,
parameters: {
nextjs: {
appDirectory: true,
},
},
} satisfies Meta<typeof Variants>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Desktop: Story = {
args: {
type: "desktop",
},
};
export const Mobile: Story = {
args: {
type: "mobile",
},
parameters: {
viewport: mobileViewport("iphone14"),
},
};
function Variants() {
return (
<ThirdwebProvider>
<div className="container py-10 max-w-[1132px] mx-auto w-full flex flex-col gap-10">
<AccountSettingsPageUI
account={{
name: "John Doe",
email: "[email protected]",
}}
/>
</div>
<Toaster richColors />
</ThirdwebProvider>
);
}
@@ -0,0 +1,335 @@
"use client";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { EllipsisIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { FileInput } from "../../../components/shared/FileInput";
import { DangerSettingCard } from "../../team/[team_slug]/(team)/~/settings/general/DangerSettingCard";
import { SettingsCard } from "../../team/[team_slug]/(team)/~/settings/general/SettingsCard";
type MinimalAccount = Pick<
Account,
"name" | "email" | "emailConfirmedAt" | "unconfirmedEmail"
>;
export function AccountSettingsPageUI(props: {
account: MinimalAccount;
}) {
return (
<div className="flex flex-col gap-8">
<AccountAvatarFormControl />
<AccountNameFormControl name={props.account.name || ""} />
<AccountEmailFormControl
email={props.account.email || ""}
// TODO - is this correct way to check this?
status={props.account.emailConfirmedAt ? "verified" : "unverified"}
/>
<DeleteAccountCard />
</div>
);
}
function AccountAvatarFormControl() {
const [avatar, setAvatar] = useState<File>(); // TODO: prefill with account avatar
// TODO - implement
const updateAvatarMutation = useMutation({
mutationFn: async (_avatar: File | undefined) => {
// Fake loading
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Updating account image to", _avatar);
throw new Error("Not implemented");
},
});
function handleSave() {
const promises = updateAvatarMutation.mutateAsync(avatar);
toast.promise(promises, {
success: "Account avatar updated successfully",
error: "Failed to update account avatar",
});
}
return (
<SettingsCard
bottomText="An avatar is optional but strongly recommended."
saveButton={{
onClick: handleSave,
disabled: false,
isLoading: updateAvatarMutation.isLoading,
}}
noPermissionText={undefined}
errorText={undefined}
>
<div className="flex flex-row gap-4 md:justify-between">
<div>
<h3 className="text-xl font-semibold tracking-tight">Avatar</h3>
<p className="text-foreground text-sm mt-1.5 mb-4 leading-relaxed">
This is your account's avatar. <br /> Click on the avatar to upload
a custom one
</p>
</div>
<FileInput
accept={{ "image/*": [] }}
value={avatar}
setValue={setAvatar}
className="w-20 lg:w-28 rounded-full"
disableHelperText
/>
</div>
</SettingsCard>
);
}
function AccountNameFormControl(props: {
name: string;
}) {
const [accountName, setAccountName] = useState(props.name);
const maxAccountNameLength = 32;
// TODO - implement
const updateAccountNameMutation = useMutation({
mutationFn: async (name: string) => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Updating account name to", name);
throw new Error("Not implemented");
},
});
function handleSave() {
const promises = updateAccountNameMutation.mutateAsync(accountName);
toast.promise(promises, {
success: "Account name updated successfully",
error: "Failed to update account name",
});
}
return (
<SettingsCard
header={{
title: "Display Name",
description: "This is your account's name displayed on thirdweb",
}}
bottomText={`Please use ${maxAccountNameLength} characters at maximum.`}
saveButton={{
onClick: handleSave,
disabled: accountName.length === 0,
isLoading: updateAccountNameMutation.isLoading,
}}
errorText={undefined}
noPermissionText={undefined} // TODO
>
<Input
value={accountName}
onChange={(e) => {
setAccountName(e.target.value.slice(0, maxAccountNameLength));
}}
className="md:w-[400px]"
/>
</SettingsCard>
);
}
function DeleteAccountCard() {
const router = useDashboardRouter();
const title = "Delete Account";
const description =
"Permanently remove your Personal Account and all of its contents from the thirdweb platform. This action is not reversible, please continue with caution.";
// TODO
const deleteAccount = useMutation({
mutationFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Deleting account");
throw new Error("Not implemented");
},
onSuccess: () => {
router.push("/team");
},
});
function handleDelete() {
const promises = deleteAccount.mutateAsync();
toast.promise(promises, {
success: "Account deleted successfully",
error: "Failed to delete account",
});
}
return (
<DangerSettingCard
title={title}
description={description}
buttonLabel={title}
buttonOnClick={handleDelete}
isLoading={deleteAccount.isLoading}
confirmationDialog={{
title: "Are you sure you want to delete your account?",
description:
"This action is not reversible and will delete all of your data.",
}}
/>
);
}
function AccountEmailFormControl(props: {
email: string;
status: "unverified" | "verfication-sent" | "verified";
}) {
// TODO - query for account changes when the email is updated
return (
<SettingsCard
header={{
title: "Email",
description:
"Enter the email address you want to use to log in with thirdweb. This email will be used for account related notifications",
}}
bottomText={
"Emails must be verified to be able to login with them or be used as primary email"
}
errorText={undefined}
noPermissionText={undefined}
>
<div className="flex gap-4 justify-between items-center border rounded-lg p-4 bg-card">
{/* Start */}
<div className="flex flex-col lg:flex-row lg:items-center items-start gap-2 lg:gap-3">
<p className="text-sm"> {props.email}</p>
<Badge
className="capitalize "
variant={props.status === "unverified" ? "outline" : "default"}
>
{props.status}
</Badge>
</div>
{/* End */}
<EmailUpdateDialog
currentEmail={props.email}
trigger={
<Button
size="icon"
variant="ghost"
className="!h-auto !w-auto p-1.5"
>
<EllipsisIcon className="size-5 text-muted-foreground" />
</Button>
}
/>
</div>
</SettingsCard>
);
}
const emailUpdateFormSchema = z.object({
email: z.string().min(1, "Email can not be empty").max(100),
});
function EmailUpdateDialog(props: {
currentEmail: string;
trigger: React.ReactNode;
}) {
const form = useForm<z.infer<typeof emailUpdateFormSchema>>({
resolver: zodResolver(emailUpdateFormSchema),
values: {
email: props.currentEmail,
},
});
// TODO - implement
const updateEmailMutation = useMutation({
mutationFn: async (_email: string) => {
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Updating account email to", _email);
throw new Error("Not implemented");
},
});
function onSubmit(values: z.infer<typeof emailUpdateFormSchema>) {
const promises = updateEmailMutation.mutateAsync(values.email);
toast.promise(promises, {
success: "Email updated successfully",
error: "Failed to update email",
});
}
return (
<Dialog>
<DialogTrigger asChild>{props.trigger}</DialogTrigger>
<DialogContent className="p-0">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="p-6 flex flex-col gap-4 pb-10">
<DialogHeader className="pr-10">
<DialogTitle className="text-2xl">Update Email</DialogTitle>
<DialogDescription>
A confirmation email will be sent to verify email address
</DialogDescription>
</DialogHeader>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
{...field}
className="selection:bg-foreground/20"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter className="gap-4 lg:gap-1 bg-muted/50 p-6 border-t">
<DialogClose asChild>
<Button variant="outline"> Cancel </Button>
</DialogClose>
<Button className="min-w-24 gap-2" type="submit">
{updateEmailMutation.isLoading && (
<Spinner className="size-4" />
)}
{updateEmailMutation.isLoading ? "Saving" : "Save"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,27 @@
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
import { getAuthToken } from "../../api/lib/getAuthToken";
export async function getAccount() {
const authToken = getAuthToken();
const apiServerURL = new URL(
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com",
);
apiServerURL.pathname = "/v1/account/me";
const res = await fetch(apiServerURL, {
method: "GET",
headers: {
Authorization: `Bearer ${authToken}`,
},
});
const json = await res.json();
if (json.error) {
console.error(json.error);
return undefined;
}
return json.data as Account;
}
@@ -1,9 +1,13 @@
export default function Page() {
return (
<div className="flex items-center justify-center grow">
<h1 className="text-3xl font-medium text-muted-foreground tracking-tight">
Account Settings
</h1>
</div>
);
import { redirect } from "next/navigation";
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
import { getAccount } from "./getAccount";
export default async function Page() {
const account = await getAccount();
if (!account) {
redirect(`/login?next=${encodeURIComponent("/account")}`);
}
return <AccountSettingsPageUI account={account} />;
}
@@ -57,8 +57,8 @@ export function LinkWalletUI(props: {
{/* No Result Found */}
{walletsToShow.length === 0 && (
<div className="p-4 h-[200px] justify-center flex items-center">
<div className="text-center">
<p className="mb-3 text-sm">No Wallets Found</p>
<div className="text-center flex flex-col gap-3">
<p className="text-sm">No Wallets Found</p>
{searchValue && (
<p className="text-sm text-muted-foreground">
Your search for {`"${searchValue}"`} did not match any wallets
@@ -4,7 +4,7 @@ import { BadgeContainer, mobileViewport } from "../../../stories/utils";
import { LinkWalletUI } from "./LinkWalletUI";
const meta = {
title: "Account/LinkWallets",
title: "Account/Pages/Link Wallets",
component: Variants,
parameters: {
nextjs: {
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CmdKSearchModal } from "components/cmd-k-search";
@@ -29,48 +30,47 @@ export function ResourcesDropdownButton() {
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-4 min-w-[230px]" sideOffset={14}>
<div className="flex flex-col gap-4">
<Link
href="/chainlist"
className="text-muted-foreground hover:text-foreground text-sm flex items-center gap-2"
>
<DropdownMenuContent className="min-w-[230px]" sideOffset={14}>
<DropdownMenuItem>
<Link href="/chainlist" className="w-full p-1">
Chainlist
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link
href="https://playground.thirdweb.com/"
target="_blank"
className="text-muted-foreground hover:text-foreground text-sm flex items-center gap-2"
className="w-full p-1"
>
Playground
</Link>
</DropdownMenuItem>
<Link
href="/explore"
className="text-muted-foreground hover:text-foreground text-sm"
>
<DropdownMenuItem>
<Link href="/explore" className="w-full p-1">
Explore Contracts
</Link>
</DropdownMenuItem>
<Link
href="/trending"
className="text-muted-foreground hover:text-foreground text-sm"
>
<DropdownMenuItem>
<Link href="/trending" className="w-full p-1">
Popular Contracts
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Button
variant="link"
className="text-muted-foreground text-sm gap-2 !p-0 !h-auto hover:!no-underline hover:text-foreground text-left justify-between"
variant="ghost"
className="text-sm gap-2 !p-1 !h-auto text-left justify-between w-full"
onClick={() => setIsCMDSearchModalOpen(true)}
>
Search Contracts
<span className="items-center flex gap-0.5 text-xs opacity-80">
<span className="items-center flex gap-0.5 text-xs text-muted-foreground">
<CommandIcon className="size-3" />K
</span>
</Button>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
@@ -6,6 +6,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SkeletonContainer } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { LogOutIcon, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
@@ -30,15 +31,16 @@ export function AccountButton(props: {
<div className="size-9 rounded-full bg-muted border border-border" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="p-0 w-[300px] rounded-lg"
align="end"
sideOffset={16}
>
<DropdownMenuContent className="p-0 w-[300px] rounded-lg" align="end">
<div className="p-4 pb-5 border-b border-border">
<p className="text-sm text-muted-foreground">{props.email}</p>
<SkeletonContainer
skeletonData="[email protected]"
loadedData={props.email}
render={(v) => <p className="text-sm text-muted-foreground">{v}</p>}
/>
<div className="h-3" />
<div className="[&>button]:!w-full">{props.connectButton}</div>
<div className="[&>*]:!w-full">{props.connectButton}</div>
</div>
<div className="flex flex-col gap-4 px-4 py-5">
@@ -40,6 +40,10 @@ function Story() {
buttonLabel="Some Action"
buttonOnClick={() => {}}
isLoading={false}
confirmationDialog={{
title: "This is confirmation title",
description: "This is confirmation description",
}}
/>
</BadgeContainer>
@@ -50,6 +54,10 @@ function Story() {
buttonLabel="Some Action"
buttonOnClick={() => {}}
isLoading={true}
confirmationDialog={{
title: "This is confirmation title",
description: "This is confirmation description",
}}
/>
</BadgeContainer>
</div>
@@ -1,12 +1,27 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export function DangerSettingCard(props: {
title: string;
description: string;
buttonLabel: string;
buttonOnClick: () => void;
isLoading: boolean;
confirmationDialog: {
title: string;
description: string;
};
}) {
return (
<div className="border border-red-500/70 rounded-lg overflow-hidden">
@@ -19,14 +34,46 @@ export function DangerSettingCard(props: {
</div>
<div className="bg-red-100 dark:bg-red-500/20 px-4 lg:px-6 py-4 flex justify-end border-red-500/70 border-t">
<Button
variant="destructive"
className="bg-red-600 hover:bg-red-600/80 text-white font-semibold gap-2"
onClick={props.buttonOnClick}
>
{props.isLoading && <Spinner className="size-3" />}
{props.buttonLabel}
</Button>
<Dialog>
<DialogTrigger asChild>
<Button
variant="destructive"
className="bg-red-600 hover:bg-red-600/80 text-white font-semibold gap-2"
disabled={props.isLoading}
>
{props.isLoading && <Spinner className="size-3" />}
{props.buttonLabel}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="pr-10">
<DialogTitle className="leading-snug">
{props.confirmationDialog.title}
</DialogTitle>
<DialogDescription>
{props.confirmationDialog.description}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4 gap-4 lg:gap-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
className="bg-red-600 hover:bg-red-600/80 text-white font-semibold gap-2"
onClick={props.buttonOnClick}
disabled={props.isLoading}
>
{props.isLoading && <Spinner className="size-3" />}
{props.buttonLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
@@ -69,10 +69,10 @@ function ComponentVariantions() {
<div className="max-w-[1100px] mx-auto">
<h2 className="mb-5 text-3xl"> Component variations </h2>
<div className="flex flex-col gap-6">
<LeaveTeamCard enabled={true} />
<LeaveTeamCard enabled={false} />
<DeleteTeamCard enabled={true} />
<DeleteTeamCard enabled={false} />
<LeaveTeamCard enabled={true} teamName="foo" />
<LeaveTeamCard enabled={false} teamName="foo" />
<DeleteTeamCard enabled={true} teamName="foo" />
<DeleteTeamCard enabled={false} teamName="foo" />
</div>
</div>
</div>
@@ -3,6 +3,7 @@
import type { Team } from "@/api/team";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Input } from "@/components/ui/input";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useMutation } from "@tanstack/react-query";
import { FileInput } from "components/shared/FileInput";
import { useState } from "react";
@@ -20,8 +21,11 @@ export function GeneralSettingsPage(props: {
<TeamSlugFormControl team={props.team} />
<TeamAvatarFormControl />
<TeamIdCard team={props.team} />
<LeaveTeamCard enabled={false} />
<DeleteTeamCard enabled={hasPermissionToDelete} />
<LeaveTeamCard enabled={false} teamName={props.team.name} />
<DeleteTeamCard
enabled={hasPermissionToDelete}
teamName={props.team.name}
/>
</div>
);
}
@@ -181,7 +185,7 @@ function TeamAvatarFormControl() {
accept={{ "image/*": [] }}
value={teamAvatar}
setValue={setTeamAvatar}
className="w-[120px] rounded-full"
className="w-20 lg:w-28 rounded-full"
disableHelperText
/>
</div>
@@ -216,6 +220,7 @@ function TeamIdCard(props: {
export function LeaveTeamCard(props: {
enabled: boolean;
teamName: string;
}) {
const title = "Leave Team";
const description =
@@ -246,6 +251,11 @@ export function LeaveTeamCard(props: {
buttonLabel={title}
buttonOnClick={handleLeave}
isLoading={leaveTeam.isLoading}
confirmationDialog={{
title: `Are you sure you want to leave team "${props.teamName}" ?`,
description:
"This will revoke your access to this Team. Any resources you've added to the Team will remain.",
}}
/>
);
}
@@ -267,7 +277,9 @@ export function LeaveTeamCard(props: {
export function DeleteTeamCard(props: {
enabled: boolean;
teamName: string;
}) {
const router = useDashboardRouter();
const title = "Delete Team";
const description =
"Permanently remove your team and all of its contents from the thirdweb platform. This action is not reversible - please continue with caution.";
@@ -279,6 +291,9 @@ export function DeleteTeamCard(props: {
console.log("Deleting team");
throw new Error("Not implemented");
},
onSuccess: () => {
router.push("/team");
},
});
function handleDelete() {
@@ -297,6 +312,10 @@ export function DeleteTeamCard(props: {
buttonLabel={title}
buttonOnClick={handleDelete}
isLoading={deleteTeam.isLoading}
confirmationDialog={{
title: `Are you sure you want to delete team "${props.teamName}" ?`,
description: description,
}}
/>
);
}
@@ -6,6 +6,7 @@ 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 { useActiveWallet, useDisconnect } from "thirdweb/react";
import {
type TeamHeaderCompProps,
TeamHeaderDesktopUI,
@@ -18,9 +19,14 @@ export function TeamHeader(props: {
currentProject: Project | undefined;
}) {
const myAccountQuery = useAccount();
const activeWallet = useActiveWallet();
const { disconnect } = useDisconnect();
const router = useDashboardRouter();
const logout = useCallback(async () => {
if (activeWallet) {
disconnect(activeWallet);
}
// log out the user
try {
await fetch("/api/auth/logout", {
@@ -30,7 +36,7 @@ export function TeamHeader(props: {
} catch (e) {
console.error("Failed to log out", e);
}
}, [router]);
}, [router, activeWallet, disconnect]);
const headerProps: TeamHeaderCompProps = {
currentProject: props.currentProject,