mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add email verification and dark mode
This commit is contained in:
@@ -29,6 +29,7 @@ export default async function AdminSettingsPage() {
|
||||
maintenanceNotice: config.maintenanceNotice,
|
||||
siteNotice: config.siteNotice,
|
||||
allowRegistration: config.allowRegistration,
|
||||
emailVerificationRequired: config.emailVerificationRequired,
|
||||
requireInviteCode: config.requireInviteCode,
|
||||
autoReminderDispatchEnabled: config.autoReminderDispatchEnabled,
|
||||
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
|
||||
@@ -39,6 +40,13 @@ export default async function AdminSettingsPage() {
|
||||
inviteRewardCouponId: config.inviteRewardCouponId,
|
||||
turnstileSiteKey: config.turnstileSiteKey,
|
||||
turnstileSecretKey: config.turnstileSecretKey,
|
||||
smtpEnabled: config.smtpEnabled,
|
||||
smtpHost: config.smtpHost,
|
||||
smtpPort: config.smtpPort,
|
||||
smtpSecure: config.smtpSecure,
|
||||
smtpUser: config.smtpUser,
|
||||
smtpFromName: config.smtpFromName,
|
||||
smtpFromEmail: config.smtpFromEmail,
|
||||
}}
|
||||
coupons={coupons}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bell, Clock3, Gift, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { Bell, Clock3, Gift, Mail, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { saveAppSettings } from "@/actions/admin/settings";
|
||||
import { saveAppSettings, sendSmtpTestMessage } from "@/actions/admin/settings";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
@@ -17,6 +17,7 @@ interface AppConfig {
|
||||
maintenanceNotice: string | null;
|
||||
siteNotice: string | null;
|
||||
allowRegistration: boolean;
|
||||
emailVerificationRequired: boolean;
|
||||
requireInviteCode: boolean;
|
||||
autoReminderDispatchEnabled: boolean;
|
||||
reminderDispatchIntervalMinutes: number;
|
||||
@@ -27,6 +28,13 @@ interface AppConfig {
|
||||
inviteRewardCouponId: string | null;
|
||||
turnstileSiteKey: string | null;
|
||||
turnstileSecretKey: string | null;
|
||||
smtpEnabled: boolean;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUser: string | null;
|
||||
smtpFromName: string | null;
|
||||
smtpFromEmail: string | null;
|
||||
}
|
||||
|
||||
interface CouponOption {
|
||||
@@ -39,6 +47,7 @@ const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-s
|
||||
|
||||
export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: CouponOption[] }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingEmail, setTestingEmail] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setSaving(true);
|
||||
@@ -52,8 +61,23 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestEmail() {
|
||||
const form = document.getElementById("app-settings-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
|
||||
setTestingEmail(true);
|
||||
try {
|
||||
await sendSmtpTestMessage(new FormData(form));
|
||||
toast.success("测试邮件已发送");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "测试邮件发送失败"));
|
||||
} finally {
|
||||
setTestingEmail(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="form-panel space-y-6">
|
||||
<form id="app-settings-form" action={handleSubmit} className="form-panel space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Settings2 className="size-5" />
|
||||
@@ -163,9 +187,80 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
<option value="true">是</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="emailVerificationRequired">注册邮箱验证</Label>
|
||||
<select
|
||||
id="emailVerificationRequired"
|
||||
name="emailVerificationRequired"
|
||||
defaultValue={String(config.emailVerificationRequired)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="false">关闭</option>
|
||||
<option value="true">开启,注册后必须验证邮箱</option>
|
||||
</select>
|
||||
<p className="text-xs leading-5 text-muted-foreground">开启后,新用户注册会先收到验证邮件,完成验证后才能登录。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Mail className="size-4 text-primary" /> SMTP 邮件服务
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
用于注册邮箱验证、忘记密码和账户邮箱变更。密码留空会保留当前配置;测试邮件会使用已保存的配置。
|
||||
</p>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpEnabled">邮件服务</Label>
|
||||
<select id="smtpEnabled" name="smtpEnabled" defaultValue={String(config.smtpEnabled)} className={selectClassName}>
|
||||
<option value="false">关闭</option>
|
||||
<option value="true">开启</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpHost">SMTP 主机</Label>
|
||||
<Input id="smtpHost" name="smtpHost" defaultValue={config.smtpHost ?? ""} placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpPort">SMTP 端口</Label>
|
||||
<Input id="smtpPort" name="smtpPort" type="number" min={1} max={65535} defaultValue={config.smtpPort} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpSecure">TLS / SSL</Label>
|
||||
<select id="smtpSecure" name="smtpSecure" defaultValue={String(config.smtpSecure)} className={selectClassName}>
|
||||
<option value="false">STARTTLS / 普通连接</option>
|
||||
<option value="true">SSL 直连</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpUser">SMTP 用户名</Label>
|
||||
<Input id="smtpUser" name="smtpUser" defaultValue={config.smtpUser ?? ""} autoComplete="username" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpPassword">SMTP 密码</Label>
|
||||
<Input id="smtpPassword" name="smtpPassword" type="password" placeholder="留空保持不变" autoComplete="new-password" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpFromName">发件名称</Label>
|
||||
<Input id="smtpFromName" name="smtpFromName" defaultValue={config.smtpFromName ?? ""} placeholder={config.siteName} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpFromEmail">发件邮箱</Label>
|
||||
<Input id="smtpFromEmail" name="smtpFromEmail" type="email" defaultValue={config.smtpFromEmail ?? ""} placeholder="noreply@example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpTestEmail">测试收件邮箱</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="smtpTestEmail" name="smtpTestEmail" type="email" placeholder="you@example.com" />
|
||||
<Button type="button" variant="outline" onClick={handleTestEmail} disabled={testingEmail}>
|
||||
<Send className="size-4" />
|
||||
{testingEmail ? "发送中" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
|
||||
60
src/app/(auth)/forgot-password/forgot-password-client.tsx
Normal file
60
src/app/(auth)/forgot-password/forgot-password-client.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Mail } from "lucide-react";
|
||||
import { requestPasswordReset } from "@/actions/auth/email";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
|
||||
|
||||
export function ForgotPasswordClient() {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await requestPasswordReset(formData);
|
||||
setSent(true);
|
||||
} catch (error) {
|
||||
setError(getErrorMessage(error, "发送失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<AuthCard title="找回密码" description="输入注册邮箱,我们会发送一封密码重设邮件。">
|
||||
{sent ? (
|
||||
<div className="space-y-4 py-3 text-center">
|
||||
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<Mail className="size-5" />
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-muted-foreground">如果邮箱存在且状态正常,重设链接已经发送。请在 20 分钟内完成操作。</p>
|
||||
<Link href="/login" className="text-sm font-medium text-primary hover:underline">返回登录</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<AuthErrorMessage message={error} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading}>
|
||||
{loading ? "发送中..." : "发送重设邮件"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
想起密码了? <Link href="/login" className="font-medium text-primary hover:underline">去登录</Link>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
11
src/app/(auth)/forgot-password/page.tsx
Normal file
11
src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ForgotPasswordClient } from "./forgot-password-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "找回密码",
|
||||
description: "通过邮箱重设 J-Board 账户密码。",
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return <ForgotPasswordClient />;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function LoginPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
});
|
||||
setLoading(false);
|
||||
if (result?.error) {
|
||||
setError("邮箱或密码错误");
|
||||
setError(result.error === "EMAIL_NOT_VERIFIED" ? "邮箱尚未验证,请先查收验证邮件" : "邮箱或密码错误");
|
||||
} else {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
@@ -59,12 +59,26 @@ export function LoginPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
没有账户?{" "}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
注册
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-sm text-muted-foreground">
|
||||
<Link href="/forgot-password" className="font-medium text-primary hover:underline">
|
||||
忘记密码
|
||||
</Link>
|
||||
</p>
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden />
|
||||
<span>
|
||||
没有账户?{" "}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
注册
|
||||
</Link>
|
||||
</span>
|
||||
{error === "邮箱尚未验证,请先查收验证邮件" && (
|
||||
<>
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden />
|
||||
<Link href="/verify-email-request" className="font-medium text-primary hover:underline">
|
||||
重发验证邮件
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [requiresEmailVerification, setRequiresEmailVerification] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -47,6 +48,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
if (!response.ok) {
|
||||
setError(data.error || "注册失败");
|
||||
} else {
|
||||
setRequiresEmailVerification(Boolean(data.requiresEmailVerification));
|
||||
setSuccess(true);
|
||||
}
|
||||
} finally {
|
||||
@@ -60,10 +62,12 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
<AuthCard>
|
||||
<div className="space-y-4 py-3 text-center">
|
||||
<div className="text-4xl" aria-hidden="true">🎉</div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">注册成功</h1>
|
||||
<p className="text-sm text-muted-foreground">账户已创建,请登录。</p>
|
||||
<Link href="/login" className={buttonVariants({ size: "lg", className: "w-full" })}>
|
||||
去登录
|
||||
<h1 className="text-xl font-semibold tracking-tight">{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{requiresEmailVerification ? "请查收邮箱并完成验证,验证后即可登录。" : "账户已创建,请登录。"}
|
||||
</p>
|
||||
<Link href={requiresEmailVerification ? "/verify-email-request" : "/login"} className={buttonVariants({ size: "lg", className: "w-full" })}>
|
||||
{requiresEmailVerification ? "没有收到?重新发送" : "去登录"}
|
||||
</Link>
|
||||
</div>
|
||||
</AuthCard>
|
||||
|
||||
16
src/app/(auth)/reset-password/page.tsx
Normal file
16
src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ResetPasswordClient } from "./reset-password-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "重设密码",
|
||||
description: "设置新的 J-Board 账户密码。",
|
||||
};
|
||||
|
||||
export default async function ResetPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
return <ResetPasswordClient token={params.token ?? ""} />;
|
||||
}
|
||||
62
src/app/(auth)/reset-password/reset-password-client.tsx
Normal file
62
src/app/(auth)/reset-password/reset-password-client.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { resetPasswordByEmail } from "@/actions/auth/email";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
|
||||
|
||||
export function ResetPasswordClient({ token }: { token: string }) {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await resetPasswordByEmail(formData);
|
||||
setDone(true);
|
||||
} catch (error) {
|
||||
setError(getErrorMessage(error, "重设失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<AuthCard title="设置新密码" description="为你的账户设置一个新的登录密码。">
|
||||
{done ? (
|
||||
<div className="space-y-4 py-3 text-center">
|
||||
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<KeyRound className="size-5" />
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-muted-foreground">密码已更新,请使用新密码登录。</p>
|
||||
<Link href="/login" className="font-medium text-primary hover:underline">返回登录</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<AuthErrorMessage message={error} />
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">新密码</Label>
|
||||
<Input id="password" name="password" type="password" autoComplete="new-password" minLength={6} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认新密码</Label>
|
||||
<Input id="confirmPassword" name="confirmPassword" type="password" autoComplete="new-password" minLength={6} required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading || !token}>
|
||||
{loading ? "保存中..." : "更新密码"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
11
src/app/(auth)/verify-email-request/page.tsx
Normal file
11
src/app/(auth)/verify-email-request/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { VerifyEmailRequestClient } from "./verify-email-request-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "重新发送验证邮件",
|
||||
description: "重新发送 J-Board 注册邮箱验证邮件。",
|
||||
};
|
||||
|
||||
export default function VerifyEmailRequestPage() {
|
||||
return <VerifyEmailRequestClient />;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { MailCheck } from "lucide-react";
|
||||
import { requestRegistrationVerification } from "@/actions/auth/email";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
|
||||
|
||||
export function VerifyEmailRequestClient() {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await requestRegistrationVerification(formData);
|
||||
setSent(true);
|
||||
} catch (error) {
|
||||
setError(getErrorMessage(error, "发送失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<AuthCard title="重新发送验证邮件" description="没有收到邮件时,可以重新发送一次。">
|
||||
{sent ? (
|
||||
<div className="space-y-4 py-3 text-center">
|
||||
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<MailCheck className="size-5" />
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-muted-foreground">如果账户存在且尚未验证,新的验证邮件已经发送。</p>
|
||||
<Link href="/login" className="font-medium text-primary hover:underline">返回登录</Link>
|
||||
</div>
|
||||
) : (
|
||||
<form action={handleSubmit} className="space-y-4">
|
||||
<AuthErrorMessage message={error} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">注册邮箱</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading}>
|
||||
{loading ? "发送中..." : "重新发送"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ShieldCheck, UserRound } from "lucide-react";
|
||||
import { MailCheck, ShieldCheck, UserRound } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -14,12 +14,20 @@ import type { AccountPanelUser } from "../account-types";
|
||||
type AccountFormAction = (formData: FormData) => void | Promise<void>;
|
||||
|
||||
interface AccountProfileCardProps {
|
||||
user: Pick<AccountPanelUser, "email" | "name">;
|
||||
user: Pick<AccountPanelUser, "email" | "emailVerifiedAt" | "name">;
|
||||
isSaving: boolean;
|
||||
isEmailSaving: boolean;
|
||||
onSubmit: AccountFormAction;
|
||||
onEmailSubmit: AccountFormAction;
|
||||
}
|
||||
|
||||
export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileCardProps) {
|
||||
export function AccountProfileCard({
|
||||
user,
|
||||
isSaving,
|
||||
isEmailSaving,
|
||||
onSubmit,
|
||||
onEmailSubmit,
|
||||
}: AccountProfileCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-1">
|
||||
@@ -29,31 +37,43 @@ export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileC
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle>账户资料</CardTitle>
|
||||
<CardDescription>让昵称和登录邮箱保持清晰,减少账号识别成本。</CardDescription>
|
||||
<CardDescription>昵称立即保存;邮箱变更会先发送确认邮件,新邮箱确认后才会生效。</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-5">
|
||||
<form action={onSubmit} className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Label htmlFor="name">昵称</Label>
|
||||
<Input id="name" name="name" defaultValue={user.name ?? ""} required />
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-[0.68rem] font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
<ShieldCheck className="size-3" /> 已绑定
|
||||
</span>
|
||||
</div>
|
||||
<Input id="email" value={user.email} disabled />
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Label htmlFor="name">昵称</Label>
|
||||
<Input id="name" name="name" defaultValue={user.name ?? ""} required />
|
||||
</div>
|
||||
<Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto">
|
||||
{isSaving ? "保存中..." : "保存资料"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form id="account-email-form" action={onEmailSubmit} className="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label htmlFor="accountEmail">当前邮箱</Label>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-[0.68rem] font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
<ShieldCheck className="size-3" /> {user.emailVerifiedAt ? "已验证" : "已绑定"}
|
||||
</span>
|
||||
</div>
|
||||
<Input id="accountEmail" value={user.email} disabled />
|
||||
{user.emailVerifiedAt && (
|
||||
<p className="text-xs leading-5 text-muted-foreground">验证时间:{user.emailVerifiedAt}</p>
|
||||
)}
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label htmlFor="email">新邮箱</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input id="email" name="email" type="email" autoComplete="email" placeholder="new@example.com" required />
|
||||
<Button type="submit" variant="outline" disabled={isEmailSaving} className="sm:w-auto">
|
||||
<MailCheck className="size-4" />
|
||||
{isEmailSaving ? "发送中" : "发送确认"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function getAccountPageData(userId: string): Promise<{
|
||||
where: { id: userId },
|
||||
select: {
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
name: true,
|
||||
inviteCode: true,
|
||||
createdAt: true,
|
||||
@@ -33,6 +34,7 @@ export async function getAccountPageData(userId: string): Promise<{
|
||||
return {
|
||||
user: {
|
||||
email: user.email,
|
||||
emailVerifiedAt: user.emailVerifiedAt ? formatDate(user.emailVerifiedAt) : null,
|
||||
name: user.name,
|
||||
inviteCode: user.inviteCode,
|
||||
createdAt: formatDate(user.createdAt),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import {
|
||||
changeAccountPassword,
|
||||
generateInviteCode,
|
||||
requestAccountEmailChange,
|
||||
updateAccountProfile,
|
||||
} from "@/actions/user/account";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
@@ -22,6 +23,7 @@ export function AccountPanel({ user }: Props) {
|
||||
const router = useRouter();
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [emailSaving, setEmailSaving] = useState(false);
|
||||
const [inviteCode, setInviteCode] = useState(user.inviteCode);
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
|
||||
@@ -38,6 +40,19 @@ export function AccountPanel({ user }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailSubmit(formData: FormData) {
|
||||
setEmailSaving(true);
|
||||
try {
|
||||
await requestAccountEmailChange(formData);
|
||||
toast.success("确认邮件已发送,请查收新邮箱");
|
||||
(document.getElementById("account-email-form") as HTMLFormElement | null)?.reset();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "发送确认邮件失败"));
|
||||
} finally {
|
||||
setEmailSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(formData: FormData) {
|
||||
setPasswordSaving(true);
|
||||
try {
|
||||
@@ -71,7 +86,9 @@ export function AccountPanel({ user }: Props) {
|
||||
<AccountProfileCard
|
||||
user={user}
|
||||
isSaving={profileSaving}
|
||||
isEmailSaving={emailSaving}
|
||||
onSubmit={handleProfileSubmit}
|
||||
onEmailSubmit={handleEmailSubmit}
|
||||
/>
|
||||
<AccountPasswordCard email={user.email} isSaving={passwordSaving} onSubmit={handlePasswordSubmit} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface AccountPanelUser {
|
||||
email: string;
|
||||
emailVerifiedAt: string | null;
|
||||
name: string | null;
|
||||
inviteCode: string | null;
|
||||
createdAt: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -38,7 +39,8 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: "参数错误" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { email, password, name, inviteCode, turnstileToken } = parsed.data;
|
||||
const { password, name, inviteCode, turnstileToken } = parsed.data;
|
||||
const email = normalizeEmailAddress(parsed.data.email);
|
||||
const config = await getAppConfig();
|
||||
|
||||
if (config.turnstileSecretKey) {
|
||||
@@ -69,14 +71,33 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
await prisma.user.create({
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
emailVerifiedAt: config.emailVerificationRequired ? null : new Date(),
|
||||
password: hashedPassword,
|
||||
name: name || null,
|
||||
invitedById: inviterId,
|
||||
},
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
if (config.emailVerificationRequired) {
|
||||
try {
|
||||
await sendRegistrationVerificationEmail({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
headers: req.headers,
|
||||
requestUrl: req.url,
|
||||
});
|
||||
} catch (error) {
|
||||
await prisma.user.delete({ where: { id: user.id } }).catch(() => null);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "验证邮件发送失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, requiresEmailVerification: config.emailVerificationRequired });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ThemeProvider } from "@/components/shared/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -16,10 +17,12 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" className="h-full antialiased">
|
||||
<html lang="zh-CN" className="h-full antialiased" suppressHydrationWarning>
|
||||
<body className="min-h-full flex flex-col">
|
||||
{children}
|
||||
<Toaster />
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
16
src/app/verify-email/page.tsx
Normal file
16
src/app/verify-email/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { VerifyEmailClient } from "./verify-email-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "邮箱验证",
|
||||
description: "确认 J-Board 账户邮箱。",
|
||||
};
|
||||
|
||||
export default async function VerifyEmailPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ token?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
return <VerifyEmailClient token={params.token ?? ""} />;
|
||||
}
|
||||
56
src/app/verify-email/verify-email-client.tsx
Normal file
56
src/app/verify-email/verify-email-client.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, MailCheck, XCircle } from "lucide-react";
|
||||
import { confirmEmailToken } from "@/actions/auth/email";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AuthCard, AuthErrorMessage, AuthShell } from "@/app/(auth)/_components/auth-shell";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
export function VerifyEmailClient({ token }: { token: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const Icon = result?.ok ? CheckCircle2 : result ? XCircle : MailCheck;
|
||||
|
||||
async function handleConfirm(formData: FormData) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setResult(await confirmEmailToken(formData));
|
||||
} catch (error) {
|
||||
setError(getErrorMessage(error, "验证失败"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell>
|
||||
<AuthCard
|
||||
title={result ? (result.ok ? "验证完成" : "验证失败") : "确认邮箱操作"}
|
||||
description={result?.message ?? "为了避免邮件客户端预览误触发,请点击按钮完成确认。"}
|
||||
>
|
||||
<div className="space-y-4 py-3 text-center">
|
||||
<div className={result && !result.ok ? "mx-auto flex size-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive" : "mx-auto flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary"}>
|
||||
<Icon className="size-6" />
|
||||
</div>
|
||||
<AuthErrorMessage message={error} />
|
||||
{!result ? (
|
||||
<form action={handleConfirm}>
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<Button type="submit" size="lg" disabled={loading || !token} className="w-full">
|
||||
{loading ? "确认中..." : "确认邮箱"}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Link href="/login" className="font-medium text-primary hover:underline">
|
||||
返回登录
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user