From 5385f6bde1d353c19a5a791c045718cd70a11349 Mon Sep 17 00:00:00 2001 From: MananTank Date: Thu, 12 Sep 2024 16:34:41 +0000 Subject: [PATCH] Add Account Settings page (#4558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem solved Short description of the bug fixed or feature added --- ## 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}` --- .../src/@/components/blocks/SidebarLayout.tsx | 9 +- apps/dashboard/src/app/account/layout.tsx | 8 + .../overview/AccountTeamsUI.stories.tsx | 2 +- .../AccountSettingsPageUI.stories.tsx | 49 +++ .../settings/AccountSettingsPageUI.tsx | 335 ++++++++++++++++++ .../src/app/account/settings/getAccount.ts | 27 ++ .../src/app/account/settings/page.tsx | 20 +- .../src/app/account/wallets/LinkWalletUI.tsx | 4 +- .../account/wallets/LinkWalletsUI.stories.tsx | 2 +- .../SecondaryNav/ResourcesDropdownButton.tsx | 38 +- .../SecondaryNav/account-button.client.tsx | 16 +- .../general/DangerSettingCard.stories.tsx | 8 + .../~/settings/general/DangerSettingCard.tsx | 63 +++- .../general/GeneralSettingsPage.stories.tsx | 8 +- .../settings/general/GeneralSettingsPage.tsx | 25 +- .../team/components/TeamHeader/TeamHeader.tsx | 8 +- 16 files changed, 567 insertions(+), 55 deletions(-) create mode 100644 apps/dashboard/src/app/account/settings/AccountSettingsPageUI.stories.tsx create mode 100644 apps/dashboard/src/app/account/settings/AccountSettingsPageUI.tsx create mode 100644 apps/dashboard/src/app/account/settings/getAccount.ts diff --git a/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx b/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx index 0a1af9c7d..648157cb2 100644 --- a/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx +++ b/apps/dashboard/src/@/components/blocks/SidebarLayout.tsx @@ -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 ( -
+
+
+
+

+ My Account +

+
+
; + +export default meta; +type Story = StoryObj; + +export const Desktop: Story = { + args: { + type: "desktop", + }, +}; + +export const Mobile: Story = { + args: { + type: "mobile", + }, + parameters: { + viewport: mobileViewport("iphone14"), + }, +}; + +function Variants() { + return ( + +
+ +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.tsx b/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.tsx new file mode 100644 index 000000000..f228a7a13 --- /dev/null +++ b/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.tsx @@ -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 ( +
+ + + + + +
+ ); +} + +function AccountAvatarFormControl() { + const [avatar, setAvatar] = useState(); // 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 ( + +
+
+

Avatar

+

+ This is your account's avatar.
Click on the avatar to upload + a custom one +

+
+ +
+
+ ); +} + +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 ( + + { + setAccountName(e.target.value.slice(0, maxAccountNameLength)); + }} + className="md:w-[400px]" + /> + + ); +} + +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 ( + + ); +} + +function AccountEmailFormControl(props: { + email: string; + status: "unverified" | "verfication-sent" | "verified"; +}) { + // TODO - query for account changes when the email is updated + + return ( + +
+ {/* Start */} +
+

{props.email}

+ + {props.status} + +
+ + {/* End */} + + + + } + /> +
+
+ ); +} + +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>({ + 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) { + const promises = updateEmailMutation.mutateAsync(values.email); + toast.promise(promises, { + success: "Email updated successfully", + error: "Failed to update email", + }); + } + + return ( + + {props.trigger} + + +
+ +
+ + Update Email + + A confirmation email will be sent to verify email address + + + + ( + + Email + + + + + + )} + /> +
+ + + + + + + +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/account/settings/getAccount.ts b/apps/dashboard/src/app/account/settings/getAccount.ts new file mode 100644 index 000000000..24a32f266 --- /dev/null +++ b/apps/dashboard/src/app/account/settings/getAccount.ts @@ -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; +} diff --git a/apps/dashboard/src/app/account/settings/page.tsx b/apps/dashboard/src/app/account/settings/page.tsx index 32740c1d6..0f8d695cc 100644 --- a/apps/dashboard/src/app/account/settings/page.tsx +++ b/apps/dashboard/src/app/account/settings/page.tsx @@ -1,9 +1,13 @@ -export default function Page() { - return ( -
-

- Account Settings -

-
- ); +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 ; } diff --git a/apps/dashboard/src/app/account/wallets/LinkWalletUI.tsx b/apps/dashboard/src/app/account/wallets/LinkWalletUI.tsx index 77afddef8..9c2d20bc6 100644 --- a/apps/dashboard/src/app/account/wallets/LinkWalletUI.tsx +++ b/apps/dashboard/src/app/account/wallets/LinkWalletUI.tsx @@ -57,8 +57,8 @@ export function LinkWalletUI(props: { {/* No Result Found */} {walletsToShow.length === 0 && (
-
-

No Wallets Found

+
+

No Wallets Found

{searchValue && (

Your search for {`"${searchValue}"`} did not match any wallets diff --git a/apps/dashboard/src/app/account/wallets/LinkWalletsUI.stories.tsx b/apps/dashboard/src/app/account/wallets/LinkWalletsUI.stories.tsx index 3e1b48d7c..3d1d507be 100644 --- a/apps/dashboard/src/app/account/wallets/LinkWalletsUI.stories.tsx +++ b/apps/dashboard/src/app/account/wallets/LinkWalletsUI.stories.tsx @@ -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: { diff --git a/apps/dashboard/src/app/components/Header/SecondaryNav/ResourcesDropdownButton.tsx b/apps/dashboard/src/app/components/Header/SecondaryNav/ResourcesDropdownButton.tsx index c8a5c9158..f94a99719 100644 --- a/apps/dashboard/src/app/components/Header/SecondaryNav/ResourcesDropdownButton.tsx +++ b/apps/dashboard/src/app/components/Header/SecondaryNav/ResourcesDropdownButton.tsx @@ -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() { - -

- + + + Chainlist + + Playground + - + + Explore Contracts + - + + Popular Contracts + + -
+ diff --git a/apps/dashboard/src/app/components/Header/SecondaryNav/account-button.client.tsx b/apps/dashboard/src/app/components/Header/SecondaryNav/account-button.client.tsx index 3400d2251..192d9e8cb 100644 --- a/apps/dashboard/src/app/components/Header/SecondaryNav/account-button.client.tsx +++ b/apps/dashboard/src/app/components/Header/SecondaryNav/account-button.client.tsx @@ -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: {
- +
-

{props.email}

+

{v}

} + /> +
-
{props.connectButton}
+
{props.connectButton}
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.stories.tsx index dee13f476..101ab5313 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.stories.tsx @@ -40,6 +40,10 @@ function Story() { buttonLabel="Some Action" buttonOnClick={() => {}} isLoading={false} + confirmationDialog={{ + title: "This is confirmation title", + description: "This is confirmation description", + }} /> @@ -50,6 +54,10 @@ function Story() { buttonLabel="Some Action" buttonOnClick={() => {}} isLoading={true} + confirmationDialog={{ + title: "This is confirmation title", + description: "This is confirmation description", + }} />
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.tsx index 795d4cb15..ce24eb43b 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/DangerSettingCard.tsx @@ -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 (
@@ -19,14 +34,46 @@ export function DangerSettingCard(props: {
- + + + + + + + + + {props.confirmationDialog.title} + + + + {props.confirmationDialog.description} + + + + + + + + + + + +
); diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx index 7b1f0292b..103483a9a 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.stories.tsx @@ -69,10 +69,10 @@ function ComponentVariantions() {

Component variations

- - - - + + + +
diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.tsx index 6300e3c37..0801ce751 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/general/GeneralSettingsPage.tsx @@ -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: { - - + +
); } @@ -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 />
@@ -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, + }} /> ); } diff --git a/apps/dashboard/src/app/team/components/TeamHeader/TeamHeader.tsx b/apps/dashboard/src/app/team/components/TeamHeader/TeamHeader.tsx index c1838256d..0145f3458 100644 --- a/apps/dashboard/src/app/team/components/TeamHeader/TeamHeader.tsx +++ b/apps/dashboard/src/app/team/components/TeamHeader/TeamHeader.tsx @@ -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,