mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Add email addresses section for dashboard account settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e0fa1c15d4
commit
b53e2ab0ea
@ -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 (
|
||||
<div className="border border-black/[0.08] dark:border-white/[0.08] bg-white/80 dark:bg-background/80 backdrop-blur-xl rounded-2xl p-6 shadow-sm ring-1 ring-black/[0.04] dark:ring-0">
|
||||
<h3 className="font-semibold text-base text-foreground leading-snug mb-1">Emails</h3>
|
||||
<span className="text-sm text-muted-foreground">Email management is not available in demo mode.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EmailsSectionInner user={user} />;
|
||||
}
|
||||
|
||||
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<string | null>(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<typeof emailSchema>) => {
|
||||
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 (
|
||||
<div className="border border-black/[0.08] dark:border-white/[0.08] bg-white/80 dark:bg-background/80 backdrop-blur-xl rounded-2xl p-6 shadow-sm ring-1 ring-black/[0.04] dark:ring-0 flex flex-col gap-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base text-foreground leading-snug">
|
||||
Email Addresses
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm mt-1 leading-relaxed">
|
||||
Manage your personal email addresses, primary contact, and sign-in credentials.
|
||||
</p>
|
||||
</div>
|
||||
{!addingEmail && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAddingEmail(true)}
|
||||
className="border-black/[0.08] dark:border-white/[0.08] hover:bg-zinc-50 dark:hover:bg-zinc-900 rounded-xl px-4 py-2 text-xs font-semibold w-full md:w-auto"
|
||||
>
|
||||
Add an email
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addingEmail && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
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"
|
||||
>
|
||||
<span className="text-sm font-semibold text-foreground">Add New Email</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
{...register("email")}
|
||||
placeholder="Enter email address"
|
||||
className="bg-white dark:bg-zinc-900 border-black/[0.08] dark:border-white/[0.08] rounded-xl px-3 py-2 shadow-sm focus-visible:ring-black/[0.06] dark:focus-visible:ring-white/[0.06] flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={addingEmailLoading}
|
||||
className="bg-black text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200 rounded-xl px-4"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setAddingEmail(false);
|
||||
reset();
|
||||
}}
|
||||
className="border-black/[0.08] dark:border-white/[0.08] hover:bg-zinc-50 dark:hover:bg-zinc-900 rounded-xl"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<span className="text-red-500 text-xs font-medium mt-1">{errors.email.message}</span>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{sortedEmails.length > 0 && (
|
||||
<div className="border border-black/[0.06] dark:border-white/[0.06] rounded-xl overflow-hidden shadow-sm flex flex-col divide-y divide-black/[0.04] dark:divide-white/[0.04]">
|
||||
{sortedEmails.map((cc: any) => (
|
||||
<div key={cc.id} className="flex justify-between items-center p-4 bg-white/30 dark:bg-zinc-900/30 hover:bg-zinc-50/50 dark:hover:bg-zinc-900/50 transition-colors duration-150">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="bg-zinc-100 dark:bg-zinc-900 p-2 rounded-xl text-foreground shrink-0 border border-black/[0.04] dark:border-white/[0.04]">
|
||||
<EnvelopeSimple className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-semibold text-foreground truncate">{cc.value}</span>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{cc.isPrimary && (
|
||||
<Badge className="bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900 text-[10px] px-2 py-0 border-0 font-bold rounded-full">
|
||||
Primary
|
||||
</Badge>
|
||||
)}
|
||||
{!cc.isVerified && (
|
||||
<Badge className="bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400 border border-red-200 dark:border-red-900/30 text-[9px] px-1.5 py-0 font-semibold rounded-md">
|
||||
Unverified
|
||||
</Badge>
|
||||
)}
|
||||
{cc.usedForAuth && (
|
||||
<Badge className="bg-zinc-50 text-zinc-700 dark:bg-zinc-950/40 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-900/30 text-[9px] px-1.5 py-0 font-semibold rounded-md">
|
||||
Used for sign-in
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 rounded-lg text-muted-foreground hover:text-foreground hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors"
|
||||
>
|
||||
<DotsThree className="h-5 w-5 weight-bold" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[180px] rounded-xl border-black/[0.08] dark:border-white/[0.08] shadow-md">
|
||||
{!cc.isVerified && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => { await cc.sendVerificationEmail(); }}
|
||||
className="cursor-pointer rounded-lg text-foreground focus:bg-zinc-50 dark:focus:bg-zinc-900"
|
||||
>
|
||||
Verify Email
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!cc.isPrimary && cc.isVerified && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => { await cc.update({ isPrimary: true }); }}
|
||||
className="cursor-pointer rounded-lg text-foreground focus:bg-zinc-50 dark:focus:bg-zinc-900"
|
||||
>
|
||||
Set as Primary
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!cc.isPrimary && !cc.isVerified && (
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="opacity-50 cursor-not-allowed rounded-lg"
|
||||
>
|
||||
Set as Primary (Verify first)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!cc.usedForAuth && cc.isVerified && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
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
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{cc.usedForAuth && !isLastEmailUsedForAuth && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => { await cc.update({ usedForAuth: false }); }}
|
||||
className="cursor-pointer rounded-lg text-foreground focus:bg-zinc-50 dark:focus:bg-zinc-900"
|
||||
>
|
||||
Disable Sign-in
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{cc.usedForAuth && isLastEmailUsedForAuth && (
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="opacity-50 cursor-not-allowed rounded-lg"
|
||||
>
|
||||
Disable Sign-in (Last auth email)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<div className="my-1 border-t border-black/[0.04] dark:border-white/[0.04]" />
|
||||
{(!isLastEmailUsedForAuth || !cc.usedForAuth) ? (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => { await cc.delete(); }}
|
||||
className="cursor-pointer rounded-lg text-red-500 hover:text-red-600 focus:text-red-500"
|
||||
>
|
||||
Remove Email
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="opacity-50 cursor-not-allowed rounded-lg text-red-500"
|
||||
>
|
||||
Remove Email (Last auth email)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user