From a67e980ac3607b086e4e0712c01373aeb716ea32 Mon Sep 17 00:00:00 2001 From: MananTank Date: Wed, 20 Nov 2024 21:00:48 +0000 Subject: [PATCH] [Dashboard] Feature: Add account settings page with Name and Email fields (#5461) 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 focuses on enhancing the account settings functionality, including email confirmation and account updates, while improving error handling and user interactions. ### Detailed summary - Added a new route for `/account/settings`. - Improved onboarding logic for email confirmation. - Updated waitlist joining logic to handle responses better. - Enhanced `joinTeamWaitlist` and `updateAccount` actions with better error handling. - Implemented email confirmation with OTP in `confirmEmailWithOTP`. - Improved `AccountSettingsPage` to include email update functionalities. - Added UI components for email verification and account updates. - Refined error handling in various components for better user feedback. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- apps/dashboard/src/@/actions/confirmEmail.ts | 39 +++ apps/dashboard/src/@/actions/joinWaitlist.ts | 12 +- apps/dashboard/src/@/actions/updateAccount.ts | 37 +++ apps/dashboard/src/app/account/layout.tsx | 8 +- .../account/settings/AccountSettingsPage.tsx | 61 ++-- .../AccountSettingsPageUI.stories.tsx | 70 +++- .../settings/AccountSettingsPageUI.tsx | 298 +++++++++++++----- .../components/nebula-waitlist-page.tsx | 4 +- .../src/components/onboarding/index.tsx | 5 +- 9 files changed, 405 insertions(+), 129 deletions(-) create mode 100644 apps/dashboard/src/@/actions/confirmEmail.ts create mode 100644 apps/dashboard/src/@/actions/updateAccount.ts diff --git a/apps/dashboard/src/@/actions/confirmEmail.ts b/apps/dashboard/src/@/actions/confirmEmail.ts new file mode 100644 index 00000000000..6f1216a9fea --- /dev/null +++ b/apps/dashboard/src/@/actions/confirmEmail.ts @@ -0,0 +1,39 @@ +"use server"; + +import { getAuthToken } from "../../app/api/lib/getAuthToken"; +import { API_SERVER_URL } from "../constants/env"; + +export async function confirmEmailWithOTP(otp: string) { + const token = await getAuthToken(); + + if (!token) { + return { + errorMessage: "You are not authorized to perform this action", + }; + } + + const res = await fetch(`${API_SERVER_URL}/v1/account/confirmEmail`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + confirmationToken: otp, + }), + }); + + if (!res.ok) { + const json = await res.json(); + + if (json.error) { + return { + errorMessage: json.error.message, + }; + } + + return { + errorMessage: "Failed to confirm email", + }; + } +} diff --git a/apps/dashboard/src/@/actions/joinWaitlist.ts b/apps/dashboard/src/@/actions/joinWaitlist.ts index 58f324b8481..cdc7ce0d5e5 100644 --- a/apps/dashboard/src/@/actions/joinWaitlist.ts +++ b/apps/dashboard/src/@/actions/joinWaitlist.ts @@ -12,7 +12,9 @@ export async function joinTeamWaitlist(options: { const token = await getAuthToken(); if (!token) { - throw new Error("No Auth token"); + return { + errorMessage: "You are not authorized to perform this action", + }; } const res = await fetch(`${API_SERVER_URL}/v1/teams/${teamSlug}/waitlist`, { @@ -27,8 +29,12 @@ export async function joinTeamWaitlist(options: { }); if (!res.ok) { - throw new Error("Failed to join waitlist"); + return { + errorMessage: "Failed to join waitlist", + }; } - return true; + return { + success: true, + }; } diff --git a/apps/dashboard/src/@/actions/updateAccount.ts b/apps/dashboard/src/@/actions/updateAccount.ts new file mode 100644 index 00000000000..2b196c4ec2a --- /dev/null +++ b/apps/dashboard/src/@/actions/updateAccount.ts @@ -0,0 +1,37 @@ +"use server"; +import { getAuthToken } from "../../app/api/lib/getAuthToken"; +import { API_SERVER_URL } from "../constants/env"; + +export async function updateAccount(values: { + name?: string; + email?: string; +}) { + const token = await getAuthToken(); + + if (!token) { + throw new Error("No Auth token"); + } + + const res = await fetch(`${API_SERVER_URL}/v1/account`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(values), + }); + + if (!res.ok) { + const json = await res.json(); + + if (json.error) { + return { + errorMessage: json.error.message, + }; + } + + return { + errorMessage: "Failed To Update Account", + }; + } +} diff --git a/apps/dashboard/src/app/account/layout.tsx b/apps/dashboard/src/app/account/layout.tsx index 8d0b71b782c..0cef9a46f35 100644 --- a/apps/dashboard/src/app/account/layout.tsx +++ b/apps/dashboard/src/app/account/layout.tsx @@ -47,12 +47,12 @@ async function HeaderAndNav() { name: "Contracts", exactMatch: true, }, + { + path: "/account/settings", + name: "Settings", + }, // TODO - enable these links after they are functional // { - // path: "/account/settings", - // name: "Settings", - // }, - // { // path: "/account/wallets", // name: "Wallets", // }, diff --git a/apps/dashboard/src/app/account/settings/AccountSettingsPage.tsx b/apps/dashboard/src/app/account/settings/AccountSettingsPage.tsx index e0f5b66463b..549ec3164c0 100644 --- a/apps/dashboard/src/app/account/settings/AccountSettingsPage.tsx +++ b/apps/dashboard/src/app/account/settings/AccountSettingsPage.tsx @@ -1,35 +1,56 @@ "use client"; +import { confirmEmailWithOTP } from "@/actions/confirmEmail"; +import { updateAccount } from "@/actions/updateAccount"; +import { useDashboardRouter } from "@/lib/DashboardRouter"; import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import type { ThirdwebClient } from "thirdweb"; -import { upload } from "thirdweb/storage"; import { AccountSettingsPageUI } from "./AccountSettingsPageUI"; export function AccountSettingsPage(props: { account: Account; client: ThirdwebClient; }) { + const router = useDashboardRouter(); return (
- { - if (file) { - // upload to IPFS - const ipfsUri = await upload({ - client: props.client, - files: [file], - }); - - // TODO - Implement updating the account image with uri - console.log(ipfsUri); - } else { - // TODO - Implement deleting the account image - } - - throw new Error("Not implemented"); - }} - /> +
+
+

+ Account Settings +

+
+
+
+ { + const res = await confirmEmailWithOTP(otp); + if (res?.errorMessage) { + throw new Error(res.errorMessage); + } + router.refresh(); + }} + updateName={async (name) => { + const res = await updateAccount({ name }); + if (res?.errorMessage) { + throw new Error(res.errorMessage); + } + router.refresh(); + }} + // yes, this is weird - + // to send OTP to email, we use updateAccount + sendEmail={async (email) => { + const res = await updateAccount({ email }); + if (res?.errorMessage) { + throw new Error(res.errorMessage); + } + }} + /> +
); } diff --git a/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.stories.tsx b/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.stories.tsx index 52687d5699a..2e03f07b000 100644 --- a/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.stories.tsx +++ b/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.stories.tsx @@ -1,6 +1,7 @@ +import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox"; import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; import { Toaster } from "sonner"; -import { ThirdwebProvider } from "thirdweb/react"; import { mobileViewport } from "../../../stories/utils"; import { AccountSettingsPageUI } from "./AccountSettingsPageUI"; @@ -33,20 +34,63 @@ export const Mobile: Story = { }; function Variants() { + const [isVerifiedEmail, setIsVerifiedEmail] = useState(true); + const [sendEmailFails, setSendEmailFails] = useState(false); + const [emailConfirmationFails, setEmailConfirmationFails] = useState(false); + return ( - -
- { - await new Promise((resolve) => setTimeout(resolve, 1000)); - }} - /> +
+
+ + setIsVerifiedEmail(!!v)} + /> + is Verified Email + + + + setSendEmailFails(!!v)} + /> + Sending Email Fails + + + + setEmailConfirmationFails(!!v)} + /> + Email Confirmation Fails +
+ + { + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (emailConfirmationFails) { + throw new Error("Invalid OTP"); + } + }} + updateName={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }} + sendEmail={async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + if (sendEmailFails) { + throw new Error("Email already exists"); + } + }} + /> - +
); } diff --git a/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.tsx b/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.tsx index 9d96fc6a3fe..18840ee4661 100644 --- a/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.tsx +++ b/apps/dashboard/src/app/account/settings/AccountSettingsPageUI.tsx @@ -24,10 +24,17 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; import { useDashboardRouter } from "@/lib/DashboardRouter"; +import { cn } from "@/lib/utils"; import type { Account } from "@3rdweb-sdk/react/hooks/useApi"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; import { EllipsisIcon } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -42,36 +49,44 @@ type MinimalAccount = Pick< export function AccountSettingsPageUI(props: { account: MinimalAccount; - updateAccountImage: (file: File | undefined) => Promise; + // TODO - remove hide props these when these fields are functional + hideAvatar?: boolean; + hideDeleteAccount?: boolean; + sendEmail: (email: string) => Promise; + updateName: (name: string) => Promise; + updateEmailWithOTP: (otp: string) => Promise; }) { return (
- - + {!props.hideAvatar && } + props.updateName(name)} + /> - + {!props.hideDeleteAccount && }
); } -function AccountAvatarFormControl(props: { - updateAccountImage: (file: File | undefined) => Promise; -}) { - const [avatar, setAvatar] = useState(); // TODO: prefill with account avatar +function AccountAvatarFormControl() { + const [avatar, setAvatar] = useState(); + // TODO - implement const updateAvatarMutation = useMutation({ - mutationFn: async (_avatar: File | undefined) => { - await props.updateAccountImage(_avatar); + mutationFn: async () => { + await new Promise((resolve) => setTimeout(resolve, 3000)); }, }); function handleSave() { - const promises = updateAvatarMutation.mutateAsync(avatar); + const promises = updateAvatarMutation.mutateAsync(); toast.promise(promises, { success: "Account avatar updated successfully", error: "Failed to update account avatar", @@ -111,17 +126,13 @@ function AccountAvatarFormControl(props: { function AccountNameFormControl(props: { name: string; + updateName: (name: string) => Promise; }) { 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"); - }, + mutationFn: props.updateName, }); function handleSave() { @@ -203,9 +214,10 @@ function DeleteAccountCard() { function AccountEmailFormControl(props: { email: string; status: "unverified" | "verfication-sent" | "verified"; + sendEmail: (email: string) => Promise; + updateEmailWithOTP: (otp: string) => Promise; }) { - // TODO - query for account changes when the email is updated - + const [isEmailModalOpen, setIsEmailModalOpen] = useState(false); return ( {/* End */} - + - } - /> + + + { + setIsEmailModalOpen(false); + }} + /> + +
); @@ -251,9 +272,36 @@ const emailUpdateFormSchema = z.object({ email: z.string().min(1, "Email can not be empty").max(100), }); -function EmailUpdateDialog(props: { +function EmailUpdateDialogContent(props: { currentEmail: string; - trigger: React.ReactNode; + sendEmail: (email: string) => Promise; + updateEmailWithOTP: (otp: string) => Promise; + onSuccess: () => void; +}) { + const [isEmailSent, setIsEmailSent] = useState(false); + + if (isEmailSent) { + return ( + + ); + } + + return ( + setIsEmailSent(true)} + currentEmail={props.currentEmail} + sendEmail={props.sendEmail} + /> + ); +} + +function SendEmailOTP(props: { + onEmailSent: () => void; + currentEmail: string; + sendEmail: (email: string) => Promise; }) { const form = useForm>({ resolver: zodResolver(emailUpdateFormSchema), @@ -262,71 +310,153 @@ function EmailUpdateDialog(props: { }, }); - // 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"); - }, + const [showSendError, setShowSendError] = useState(false); + const sendEmail = useMutation({ + mutationFn: props.sendEmail, }); function onSubmit(values: z.infer) { - const promises = updateEmailMutation.mutateAsync(values.email); - toast.promise(promises, { - success: "Email updated successfully", - error: "Failed to update email", + sendEmail.mutateAsync(values.email, { + onSuccess: () => { + props.onEmailSent(); + }, + onError: (e) => { + console.error(e); + setShowSendError(true); + }, }); } return ( - - {props.trigger} - - -
- -
- - Update Email - - A confirmation email will be sent to verify email address - - - - ( - - Email - - - - - + + +
+ + Update Email + + A confirmation email will be sent to verify email address + + + + ( + + Email + + { + field.onChange(e); + setShowSendError(false); + }} + /> + + + {showSendError && ( +

+ {sendEmail.error?.message || "Failed to send email"} +

)} +
+ )} + /> +
+ + + + + + + + + + ); +} + +function EnterEmailOTP(props: { + updateEmailWithOTP: (otp: string) => Promise; + onSuccess: () => void; +}) { + const [otp, setOtp] = useState(""); + const [showOTPError, setShowOTPError] = useState(false); + const updateEmail = useMutation({ + mutationFn: props.updateEmailWithOTP, + onSuccess: () => { + props.onSuccess(); + toast.success("Email updated successfully"); + }, + onError: () => { + setShowOTPError(true); + }, + }); + + return ( +
+
+ + Update Email + + Enter the OTP sent to new email address + + + +
+ + { + setOtp(v); + setShowOTPError(false); + }} + disabled={updateEmail.isPending} + > + + {new Array(6).fill(0).map((_, idx) => ( + -
- - - - - - - - - - -
+ ))} + + + + {showOTPError && ( +

+ Failed to verify email with this OTP +

+ )} + + + + + + + + + ); } diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/nebula-waitlist-page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/nebula-waitlist-page.tsx index b0a93df2de3..19b7ba6e4f7 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/nebula-waitlist-page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/nebula/components/nebula-waitlist-page.tsx @@ -26,13 +26,13 @@ export async function NebulaWaitListPage(props: { // if not already on the waitlist, join the waitlist if (!nebulaWaitList.onWaitlist) { - const joined = await joinTeamWaitlist({ + const res = await joinTeamWaitlist({ scope: "nebula", teamSlug: team.slug, }).catch(() => null); // this should never happen - if (!joined) { + if (!res?.success) { return ( ); diff --git a/apps/dashboard/src/components/onboarding/index.tsx b/apps/dashboard/src/components/onboarding/index.tsx index 2bde307f48a..6c2803d7957 100644 --- a/apps/dashboard/src/components/onboarding/index.tsx +++ b/apps/dashboard/src/components/onboarding/index.tsx @@ -56,11 +56,10 @@ export const Onboarding: React.FC<{ // user hasn't confirmed email if (!account.emailConfirmedAt && !account.unconfirmedEmail) { // if its an embedded wallet, try to auto-confirm it - setState("onboarding"); } - // user has changed email and needs to confirm - else if (account.unconfirmedEmail) { + // user has no confirmed email and they tried confirming an email earlier + else if (!account.emailConfirmedAt && account.unconfirmedEmail) { setState( account.emailConfirmationWalletAddress ? "confirmLinking"