This commit is contained in:
2026-02-03 20:00:56 +08:00
parent c4a9247cad
commit d5dde77ee9
13 changed files with 409 additions and 147 deletions

View File

@@ -91,6 +91,8 @@
"password": "Password", "password": "Password",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
"name": "Name", "name": "Name",
"username": "Username",
"emailOrUsername": "Email or Username",
"signInButton": "Sign In", "signInButton": "Sign In",
"signUpButton": "Sign Up", "signUpButton": "Sign Up",
"noAccount": "Don't have an account?", "noAccount": "Don't have an account?",
@@ -101,7 +103,11 @@
"passwordTooShort": "Password must be at least 8 characters", "passwordTooShort": "Password must be at least 8 characters",
"passwordsNotMatch": "Passwords do not match", "passwordsNotMatch": "Passwords do not match",
"nameRequired": "Please enter your name", "nameRequired": "Please enter your name",
"usernameRequired": "Please enter a username",
"usernameTooShort": "Username must be at least 3 characters",
"usernameInvalid": "Username can only contain letters, numbers, and underscores",
"emailRequired": "Please enter your email", "emailRequired": "Please enter your email",
"identifierRequired": "Please enter your email or username",
"passwordRequired": "Please enter your password", "passwordRequired": "Please enter your password",
"confirmPasswordRequired": "Please confirm your password", "confirmPasswordRequired": "Please confirm your password",
"loading": "Loading..." "loading": "Loading..."

View File

@@ -91,6 +91,8 @@
"password": "密码", "password": "密码",
"confirmPassword": "确认密码", "confirmPassword": "确认密码",
"name": "用户名", "name": "用户名",
"username": "用户名",
"emailOrUsername": "邮箱或用户名",
"signInButton": "登录", "signInButton": "登录",
"signUpButton": "注册", "signUpButton": "注册",
"noAccount": "还没有账户?", "noAccount": "还没有账户?",
@@ -101,7 +103,11 @@
"passwordTooShort": "密码至少需要8个字符", "passwordTooShort": "密码至少需要8个字符",
"passwordsNotMatch": "两次输入的密码不匹配", "passwordsNotMatch": "两次输入的密码不匹配",
"nameRequired": "请输入用户名", "nameRequired": "请输入用户名",
"usernameRequired": "请输入用户名",
"usernameTooShort": "用户名至少需要3个字符",
"usernameInvalid": "用户名只能包含字母、数字和下划线",
"emailRequired": "请输入邮箱", "emailRequired": "请输入邮箱",
"identifierRequired": "请输入邮箱或用户名",
"passwordRequired": "请输入密码", "passwordRequired": "请输入密码",
"confirmPasswordRequired": "请确认密码", "confirmPasswordRequired": "请确认密码",
"loading": "加载中..." "loading": "加载中..."

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "user" ADD COLUMN "displayUsername" TEXT,
ADD COLUMN "username" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "user_username_key" ON "user"("username");

View File

@@ -1,3 +1,4 @@
generator client { generator client {
provider = "prisma-client" provider = "prisma-client"
output = "../generated/prisma" output = "../generated/prisma"
@@ -21,8 +22,12 @@ model User {
dictionaryLookUps DictionaryLookUp[] dictionaryLookUps DictionaryLookUp[]
translationHistories TranslationHistory[] translationHistories TranslationHistory[]
username String?
displayUsername String?
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@@unique([username])
} }
model Session { model Session {

View File

@@ -6,7 +6,7 @@ import { Container } from "@/components/ui/Container";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LightButton } from "@/components/ui/buttons"; import { LightButton } from "@/components/ui/buttons";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { signInAction, signUpAction, SignUpState } from "@/modules/auth/auth-action"; import { actionSignIn, actionSignUp, ActionOutputAuth } from "@/modules/auth/auth-action";
interface AuthFormProps { interface AuthFormProps {
redirectTo?: string; redirectTo?: string;
@@ -19,22 +19,22 @@ export function AuthForm({ redirectTo }: AuthFormProps) {
const [clearSignUp, setClearSignUp] = useState(false); const [clearSignUp, setClearSignUp] = useState(false);
const [signInState, signInActionForm, isSignInPending] = useActionState( const [signInState, signInActionForm, isSignInPending] = useActionState(
async (prevState: SignUpState | undefined, formData: FormData) => { async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
if (clearSignIn) { if (clearSignIn) {
setClearSignIn(false); setClearSignIn(false);
return undefined; return undefined;
} }
return signInAction(prevState || {}, formData); return actionSignIn(undefined, formData);
}, },
undefined undefined
); );
const [signUpState, signUpActionForm, isSignUpPending] = useActionState( const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
async (prevState: SignUpState | undefined, formData: FormData) => { async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
if (clearSignUp) { if (clearSignUp) {
setClearSignUp(false); setClearSignUp(false);
return undefined; return undefined;
} }
return signUpAction(prevState || {}, formData); return actionSignUp(undefined, formData);
}, },
undefined undefined
); );
@@ -44,15 +44,32 @@ export function AuthForm({ redirectTo }: AuthFormProps) {
const validateForm = (formData: FormData): boolean => { const validateForm = (formData: FormData): boolean => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
const identifier = formData.get("identifier") as string;
const email = formData.get("email") as string; const email = formData.get("email") as string;
const username = formData.get("username") as string;
const password = formData.get("password") as string; const password = formData.get("password") as string;
const name = formData.get("name") as string;
const confirmPassword = formData.get("confirmPassword") as string; const confirmPassword = formData.get("confirmPassword") as string;
if (!email) { // 登录模式验证
newErrors.email = t("emailRequired"); if (mode === 'signin') {
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!identifier) {
newErrors.email = t("invalidEmail"); newErrors.identifier = t("identifierRequired");
}
} else {
// 注册模式验证
if (!email) {
newErrors.email = t("emailRequired");
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t("invalidEmail");
}
if (!username) {
newErrors.username = t("usernameRequired");
} else if (username.length < 3) {
newErrors.username = t("usernameTooShort");
} else if (!/^[a-zA-Z0-9_]+$/.test(username)) {
newErrors.username = t("usernameInvalid");
}
} }
if (!password) { if (!password) {
@@ -62,10 +79,6 @@ export function AuthForm({ redirectTo }: AuthFormProps) {
} }
if (mode === 'signup') { if (mode === 'signup') {
if (!name) {
newErrors.name = t("nameRequired");
}
if (!confirmPassword) { if (!confirmPassword) {
newErrors.confirmPassword = t("confirmPasswordRequired"); newErrors.confirmPassword = t("confirmPasswordRequired");
} else if (password !== confirmPassword) { } else if (password !== confirmPassword) {
@@ -128,41 +141,57 @@ export function AuthForm({ redirectTo }: AuthFormProps) {
{/* 登录/注册表单 */} {/* 登录/注册表单 */}
<form onSubmit={handleFormSubmit} className="space-y-4"> <form onSubmit={handleFormSubmit} className="space-y-4">
{/* 用户名输入(注册模式显示 */} {/* 邮箱/用户名输入(登录模式)或 用户名输入(注册模式) */}
{mode === 'signup' && ( {mode === 'signin' ? (
<div> <div>
<Input <Input
type="text" type="text"
name="name" name="identifier"
placeholder={t("name")} placeholder={t("emailOrUsername")}
className="w-full px-3 py-2" className="w-full px-3 py-2"
/> />
{/* 客户端验证错误 */} {errors.identifier && (
{errors.name && ( <p className="text-red-500 text-sm mt-1">{errors.identifier}</p>
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)} )}
{/* 服务器端验证错误 */} {currentError?.errors?.email && (
{currentError?.errors?.username && ( <p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
)} )}
</div> </div>
)} ) : (
<>
{/* 用户名输入(仅注册模式) */}
<div>
<Input
type="text"
name="username"
placeholder={t("username")}
className="w-full px-3 py-2"
/>
{errors.username && (
<p className="text-red-500 text-sm mt-1">{errors.username}</p>
)}
{currentError?.errors?.username && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
)}
</div>
{/* 邮箱输入 */} {/* 邮箱输入(仅注册模式) */}
<div> <div>
<Input <Input
type="email" type="email"
name="email" name="email"
placeholder={t("email")} placeholder={t("email")}
className="w-full px-3 py-2" className="w-full px-3 py-2"
/> />
{errors.email && ( {errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p> <p className="text-red-500 text-sm mt-1">{errors.email}</p>
)} )}
{currentError?.errors?.email && ( {currentError?.errors?.email && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p> <p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
)} )}
</div> </div>
</>
)}
{/* 密码输入 */} {/* 密码输入 */}
<div> <div>

View File

@@ -0,0 +1,8 @@
interface UserPageProps {
params: Promise<{ username: string}>;
}
export default async function UserPage({params}: UserPageProps) {
const {username} = await params;
}

View File

@@ -2,6 +2,7 @@ import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma"; import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js"; import { nextCookies } from "better-auth/next-js";
import { prisma } from "./lib/db"; import { prisma } from "./lib/db";
import { username } from "better-auth/plugins";
export const auth = betterAuth({ export const auth = betterAuth({
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
@@ -16,5 +17,5 @@ export const auth = betterAuth({
clientSecret: process.env.GITHUB_CLIENT_SECRET as string clientSecret: process.env.GITHUB_CLIENT_SECRET as string
}, },
}, },
plugins: [nextCookies()] plugins: [nextCookies(), username()]
}); });

View File

@@ -1,5 +1,9 @@
import { usernameClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL as string baseURL: process.env.BETTER_AUTH_URL as string,
plugins: [
usernameClient()
]
}); });

View File

@@ -0,0 +1,47 @@
import z from "zod";
import { generateValidator } from "@/utils/validate";
import { LENGTH_MAX_PASSWORD, LENGTH_MAX_USERNAME, LENGTH_MIN_PASSWORD, LENGTH_MIN_USERNAME } from "@/shared/constant";
// Schema for sign up
const schemaActionInputSignUp = z.object({
email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "Invalid email address"),
username: z.string().min(LENGTH_MIN_USERNAME).max(LENGTH_MAX_USERNAME).regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
password: z.string().min(LENGTH_MIN_PASSWORD).max(LENGTH_MAX_PASSWORD),
redirectTo: z.string().nullish(),
});
export type ActionInputSignUp = z.infer<typeof schemaActionInputSignUp>;
export const validateActionInputSignUp = generateValidator(schemaActionInputSignUp);
// Schema for sign in
const schemaActionInputSignIn = z.object({
identifier: z.string().min(1), // Can be email or username
password: z.string().min(LENGTH_MIN_PASSWORD).max(LENGTH_MAX_PASSWORD),
redirectTo: z.string().nullish(),
});
export type ActionInputSignIn = z.infer<typeof schemaActionInputSignIn>;
export const validateActionInputSignIn = generateValidator(schemaActionInputSignIn);
// Schema for sign out
const schemaActionInputSignOut = z.object({
redirectTo: z.string().nullish(),
});
export type ActionInputSignOut = z.infer<typeof schemaActionInputSignOut>;
export const validateActionInputSignOut = generateValidator(schemaActionInputSignOut);
// Output types
export type ActionOutputAuth = {
success: boolean;
message: string;
errors?: {
username?: string[];
email?: string[];
password?: string[];
identifier?: string[];
};
};

View File

@@ -3,131 +3,144 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ValidateError } from "@/lib/errors";
import {
ActionInputSignIn,
ActionInputSignUp,
ActionOutputAuth,
validateActionInputSignIn,
validateActionInputSignUp
} from "./auth-action-dto";
import {
serviceSignIn,
serviceSignUp
} from "./auth-service";
export interface SignUpFormData { // Re-export types for use in components
username: string; export type { ActionOutputAuth } from "./auth-action-dto";
email: string;
password: string;
}
export interface SignUpState {
success?: boolean;
message?: string;
errors?: {
username?: string[];
email?: string[];
password?: string[];
};
}
export async function signUpAction(prevState: SignUpState, formData: FormData) {
const email = formData.get("email") as string;
const name = formData.get("name") as string;
const password = formData.get("password") as string;
const redirectTo = formData.get("redirectTo") as string;
// 服务器端验证
const errors: SignUpState['errors'] = {};
if (!email) {
errors.email = ["邮箱是必填项"];
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = ["请输入有效的邮箱地址"];
}
if (!name) {
errors.username = ["姓名是必填项"];
} else if (name.length < 2) {
errors.username = ["姓名至少需要2个字符"];
}
if (!password) {
errors.password = ["密码是必填项"];
} else if (password.length < 8) {
errors.password = ["密码至少需要8个字符"];
}
// 如果有验证错误,返回错误状态
if (Object.keys(errors).length > 0) {
return {
success: false,
message: "请修正表单中的错误",
errors
};
}
/**
* Sign up action
* Creates a new user account
*/
export async function actionSignUp(prevState: ActionOutputAuth | undefined, formData: FormData): Promise<ActionOutputAuth> {
try { try {
await auth.api.signUpEmail({ // Extract form data
body: { const rawData = {
email, email: formData.get("email") as string,
password, username: formData.get("username") as string,
name password: formData.get("password") as string,
} redirectTo: formData.get("redirectTo") as string | undefined,
};
// Validate input
const dto: ActionInputSignUp = validateActionInputSignUp(rawData);
// Call service layer
const result = await serviceSignUp({
email: dto.email,
username: dto.username,
password: dto.password,
name: dto.username,
}); });
redirect(redirectTo || "/"); if (!result.success) {
} catch (error) { return {
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) { success: false,
throw error; message: "Registration failed. Email or username may already be taken.",
};
} }
// Redirect on success
redirect(dto.redirectTo || "/");
} catch (e) {
if (e instanceof Error && e.message.includes('NEXT_REDIRECT')) {
throw e;
}
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
console.error("Sign up error:", e);
return { return {
success: false, success: false,
message: "注册失败,请稍后再试" message: "Registration failed. Please try again later.",
}; };
} }
} }
export async function signInAction(prevState: SignUpState, formData: FormData) { /**
const email = formData.get("email") as string; * Sign in action
const password = formData.get("password") as string; * Authenticates a user
const redirectTo = formData.get("redirectTo") as string; */
export async function actionSignIn(_prevState: ActionOutputAuth | undefined, formData: FormData): Promise<ActionOutputAuth> {
// 服务器端验证
const errors: SignUpState['errors'] = {};
if (!email) {
errors.email = ["邮箱是必填项"];
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = ["请输入有效的邮箱地址"];
}
if (!password) {
errors.password = ["密码是必填项"];
}
// 如果有验证错误,返回错误状态
if (Object.keys(errors).length > 0) {
return {
success: false,
message: "请修正表单中的错误",
errors
};
}
try { try {
await auth.api.signInEmail({ // Extract form data
body: { const rawData = {
email, identifier: formData.get("identifier") as string,
password, password: formData.get("password") as string,
} redirectTo: formData.get("redirectTo") as string | undefined,
};
// Validate input
const dto: ActionInputSignIn = validateActionInputSignIn(rawData);
// Call service layer
const result = await serviceSignIn({
identifier: dto.identifier,
password: dto.password,
}); });
redirect(redirectTo || "/"); if (!result.success) {
} catch (error) { return {
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) { success: false,
throw error; message: "Invalid email/username or password.",
errors: {
identifier: ["Invalid email/username or password"],
},
};
} }
// Redirect on success
redirect(dto.redirectTo || "/");
} catch (e) {
if (e instanceof Error && e.message.includes('NEXT_REDIRECT')) {
throw e;
}
if (e instanceof ValidateError) {
return {
success: false,
message: e.message,
};
}
console.error("Sign in error:", e);
return { return {
success: false, success: false,
message: "登录失败,请检查您的邮箱和密码" message: "Sign in failed. Please check your credentials.",
}; };
} }
} }
/**
* Sign out action
* Signs out the current user
*/
export async function signOutAction() { export async function signOutAction() {
await auth.api.signOut({ try {
headers: await headers() await auth.api.signOut({
}); headers: await headers()
});
redirect("/auth"); redirect("/auth");
} catch (e) {
if (e instanceof Error && e.message.includes('NEXT_REDIRECT')) {
throw e;
}
console.error("Sign out error:", e);
redirect("/auth");
}
} }

View File

@@ -0,0 +1,50 @@
// Service layer DTOs for auth module
// Sign up input/output
export type ServiceInputSignUp = {
email: string;
username: string;
password: string; // plain text, will be hashed by better-auth
name: string;
};
export type ServiceOutputSignUp = {
success: boolean;
userId?: string;
email?: string;
username?: string;
};
// Sign in input/output
export type ServiceInputSignIn = {
identifier: string; // email or username
password: string;
};
export type ServiceOutputSignIn = {
success: boolean;
userId?: string;
email?: string;
username?: string;
sessionToken?: string;
};
// Sign out input/output
export type ServiceInputSignOut = {
sessionId?: string;
};
export type ServiceOutputSignOut = {
success: boolean;
};
// User existence check
export type ServiceInputCheckUserExists = {
email?: string;
username?: string;
};
export type ServiceOutputCheckUserExists = {
emailExists: boolean;
usernameExists: boolean;
};

View File

@@ -0,0 +1,76 @@
import { auth } from "@/auth";
import {
ServiceInputSignUp,
ServiceInputSignIn,
ServiceOutputSignUp,
ServiceOutputSignIn
} from "./auth-service-dto";
/**
* Sign up a new user
* Calls better-auth's signUp.email with username support
*/
export async function serviceSignUp(dto: ServiceInputSignUp): Promise<ServiceOutputSignUp> {
try {
await auth.api.signUpEmail({
body: {
email: dto.email,
password: dto.password,
username: dto.username,
name: dto.name,
}
});
return {
success: true,
email: dto.email,
username: dto.username,
};
} catch (error) {
// better-auth handles duplicates and validation errors
return {
success: false,
};
}
}
/**
* Sign in user
* Uses better-auth's signIn.username for username-based authentication
*/
export async function serviceSignIn(dto: ServiceInputSignIn): Promise<ServiceOutputSignIn> {
try {
// Determine if identifier is email or username
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(dto.identifier);
let session;
if (isEmail) {
// Use email sign in
session = await auth.api.signInEmail({
body: {
email: dto.identifier,
password: dto.password,
}
});
} else {
// Use username sign in (requires username plugin)
session = await auth.api.signInUsername({
body: {
username: dto.identifier,
password: dto.password,
}
});
}
return {
success: true,
sessionToken: session?.token,
};
} catch (error) {
// better-auth throws on invalid credentials
return {
success: false,
};
}
}

View File

@@ -15,3 +15,8 @@ export const LENGTH_MIN_FOLDER_NAME = 1;
export const LENGTH_MAX_TRANSLATOR_TEXT = 1000; export const LENGTH_MAX_TRANSLATOR_TEXT = 1000;
export const LENGTH_MIN_TRANSLATOR_TEXT = 1; export const LENGTH_MIN_TRANSLATOR_TEXT = 1;
export const LENGTH_MAX_USERNAME = 30;
export const LENGTH_MIN_USERNAME = 3;
export const LENGTH_MAX_PASSWORD = 100;
export const LENGTH_MIN_PASSWORD = 8;