...
This commit is contained in:
@@ -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..."
|
||||||
|
|||||||
@@ -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": "加载中..."
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,17 +44,34 @@ 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 (mode === 'signin') {
|
||||||
|
if (!identifier) {
|
||||||
|
newErrors.identifier = t("identifierRequired");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 注册模式验证
|
||||||
if (!email) {
|
if (!email) {
|
||||||
newErrors.email = t("emailRequired");
|
newErrors.email = t("emailRequired");
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
newErrors.email = t("invalidEmail");
|
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) {
|
||||||
newErrors.password = t("passwordRequired");
|
newErrors.password = t("passwordRequired");
|
||||||
} else if (password.length < 8) {
|
} else if (password.length < 8) {
|
||||||
@@ -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,27 +141,41 @@ 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 && (
|
||||||
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
|
||||||
|
)}
|
||||||
|
</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 && (
|
{currentError?.errors?.username && (
|
||||||
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 邮箱输入 */}
|
{/* 邮箱输入(仅注册模式) */}
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -163,6 +190,8 @@ export function AuthForm({ redirectTo }: AuthFormProps) {
|
|||||||
<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>
|
||||||
|
|||||||
8
src/app/users/[username]/page.ts
Normal file
8
src/app/users/[username]/page.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
interface UserPageProps {
|
||||||
|
params: Promise<{ username: string}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserPage({params}: UserPageProps) {
|
||||||
|
const {username} = await params;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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()]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
47
src/modules/auth/auth-action-dto.ts
Normal file
47
src/modules/auth/auth-action-dto.ts
Normal 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[];
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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) {
|
|
||||||
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "注册失败,请稍后再试"
|
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 {
|
||||||
|
success: false,
|
||||||
|
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) {
|
|
||||||
if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "登录失败,请检查您的邮箱和密码"
|
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 {
|
||||||
|
success: false,
|
||||||
|
message: "Sign in failed. Please check your credentials.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out action
|
||||||
|
* Signs out the current user
|
||||||
|
*/
|
||||||
export async function signOutAction() {
|
export async function signOutAction() {
|
||||||
|
try {
|
||||||
await auth.api.signOut({
|
await auth.api.signOut({
|
||||||
headers: await headers()
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/modules/auth/auth-service-dto.ts
Normal file
50
src/modules/auth/auth-service-dto.ts
Normal 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;
|
||||||
|
};
|
||||||
76
src/modules/auth/auth-service.ts
Normal file
76
src/modules/auth/auth-service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user