From b53e2ab0ea5cbf6bcb2e41ad62b1ee0189436d1b Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 27 May 2026 12:47:47 -0700 Subject: [PATCH] Add email addresses section for dashboard account settings. Co-authored-by: Cursor --- .../email-and-auth/emails-section.tsx | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 apps/dashboard/src/components/dashboard-account-settings/email-and-auth/emails-section.tsx diff --git a/apps/dashboard/src/components/dashboard-account-settings/email-and-auth/emails-section.tsx b/apps/dashboard/src/components/dashboard-account-settings/email-and-auth/emails-section.tsx new file mode 100644 index 000000000..f8edbf9a4 --- /dev/null +++ b/apps/dashboard/src/components/dashboard-account-settings/email-and-auth/emails-section.tsx @@ -0,0 +1,277 @@ +'use client'; + +import { yupResolver } from "@hookform/resolvers/yup"; +import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; +import { strictEmailSchema, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { DotsThree, EnvelopeSimple, Warning } from "@phosphor-icons/react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import * as yup from "yup"; +import { useUser } from "@stackframe/stack"; + +export function EmailsSection(props?: { + mockMode?: boolean, +}) { + const isInMockMode = !!props?.mockMode; + const user = useUser({ or: isInMockMode ? 'return-null' : 'redirect' }); + + // In mock mode, show a placeholder message + if (isInMockMode && !user) { + return ( +
+

Emails

+ Email management is not available in demo mode. +
+ ); + } + + if (!user) { + return null; + } + + return ; +} + +function EmailsSectionInner({ user }: { user: any }) { + const contactChannels = user.useContactChannels(); + const [addingEmail, setAddingEmail] = useState(contactChannels.length === 0); + const [addingEmailLoading, setAddingEmailLoading] = useState(false); + const [addedEmail, setAddedEmail] = useState(null); + const isLastEmailUsedForAuth = contactChannels.filter((x: any) => x.usedForAuth && (x.type as string) === 'email').length === 1; + + useEffect(() => { + if (addedEmail) { + runAsynchronously(async () => { + const cc = contactChannels.find((x: any) => x.value === addedEmail); + if (cc && !cc.isVerified) { + await cc.sendVerificationEmail(); + } + setAddedEmail(null); + }); + } + }, [contactChannels, addedEmail]); + + const emailSchema = yupObject({ + email: strictEmailSchema('Please enter a valid email address') + .notOneOf(contactChannels.map((x: any) => x.value), 'Email already exists') + .defined() + .nonEmpty('Email is required'), + }); + + const { register, handleSubmit, formState: { errors }, reset } = useForm({ + resolver: yupResolver(emailSchema) + }); + + const onSubmit = async (data: yup.InferType) => { + setAddingEmailLoading(true); + try { + await user.createContactChannel({ type: 'email', value: data.email, usedForAuth: false }); + setAddedEmail(data.email); + } finally { + setAddingEmailLoading(false); + } + setAddingEmail(false); + reset(); + }; + + const sortedEmails = [...contactChannels] + .filter((x: any) => (x.type as string) === 'email') + .sort((a: any, b: any) => { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + if (a.isVerified !== b.isVerified) return a.isVerified ? -1 : 1; + return 0; + }); + + return ( +
+
+
+

+ Email Addresses +

+

+ Manage your personal email addresses, primary contact, and sign-in credentials. +

+
+ {!addingEmail && ( + + )} +
+ + {addingEmail && ( +
{ + e.preventDefault(); + runAsynchronously(handleSubmit(onSubmit)); + }} + className="bg-zinc-50/50 dark:bg-zinc-900/50 p-4 border border-black/[0.06] dark:border-white/[0.06] rounded-xl flex flex-col gap-3 max-w-md w-full" + > + Add New Email +
+ + + +
+ {errors.email && ( + {errors.email.message} + )} +
+ )} + + {sortedEmails.length > 0 && ( +
+ {sortedEmails.map((cc: any) => ( +
+
+
+ +
+
+ {cc.value} +
+ {cc.isPrimary && ( + + Primary + + )} + {!cc.isVerified && ( + + Unverified + + )} + {cc.usedForAuth && ( + + Used for sign-in + + )} +
+
+
+ + + + + + + {!cc.isVerified && ( + { await cc.sendVerificationEmail(); }} + className="cursor-pointer rounded-lg text-foreground focus:bg-zinc-50 dark:focus:bg-zinc-900" + > + Verify Email + + )} + {!cc.isPrimary && cc.isVerified && ( + { await cc.update({ isPrimary: true }); }} + className="cursor-pointer rounded-lg text-foreground focus:bg-zinc-50 dark:focus:bg-zinc-900" + > + Set as Primary + + )} + {!cc.isPrimary && !cc.isVerified && ( + + Set as Primary (Verify first) + + )} + {!cc.usedForAuth && cc.isVerified && ( + { + try { + await cc.update({ usedForAuth: true }); + } catch (e) { + if (KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse.isInstance(e)) { + alert("This email is already used for sign-in by another user."); + } + } + }} + className="cursor-pointer rounded-lg text-foreground focus:bg-zinc-50 dark:focus:bg-zinc-900" + > + Enable Sign-in + + )} + {cc.usedForAuth && !isLastEmailUsedForAuth && ( + { await cc.update({ usedForAuth: false }); }} + className="cursor-pointer rounded-lg text-foreground focus:bg-zinc-50 dark:focus:bg-zinc-900" + > + Disable Sign-in + + )} + {cc.usedForAuth && isLastEmailUsedForAuth && ( + + Disable Sign-in (Last auth email) + + )} +
+ {(!isLastEmailUsedForAuth || !cc.usedForAuth) ? ( + { await cc.delete(); }} + className="cursor-pointer rounded-lg text-red-500 hover:text-red-600 focus:text-red-500" + > + Remove Email + + ) : ( + + Remove Email (Last auth email) + + )} + + +
+ ))} +
+ )} +
+ ); +}