diff --git a/messages/en-US.json b/messages/en-US.json index c3f46c0..dc8a0b7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -91,6 +91,8 @@ "password": "Password", "confirmPassword": "Confirm Password", "name": "Name", + "username": "Username", + "emailOrUsername": "Email or Username", "signInButton": "Sign In", "signUpButton": "Sign Up", "noAccount": "Don't have an account?", @@ -101,7 +103,11 @@ "passwordTooShort": "Password must be at least 8 characters", "passwordsNotMatch": "Passwords do not match", "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", + "identifierRequired": "Please enter your email or username", "passwordRequired": "Please enter your password", "confirmPasswordRequired": "Please confirm your password", "loading": "Loading..." diff --git a/messages/zh-CN.json b/messages/zh-CN.json index ff8bdb1..96dc73b 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -91,6 +91,8 @@ "password": "密码", "confirmPassword": "确认密码", "name": "用户名", + "username": "用户名", + "emailOrUsername": "邮箱或用户名", "signInButton": "登录", "signUpButton": "注册", "noAccount": "还没有账户?", @@ -101,7 +103,11 @@ "passwordTooShort": "密码至少需要8个字符", "passwordsNotMatch": "两次输入的密码不匹配", "nameRequired": "请输入用户名", + "usernameRequired": "请输入用户名", + "usernameTooShort": "用户名至少需要3个字符", + "usernameInvalid": "用户名只能包含字母、数字和下划线", "emailRequired": "请输入邮箱", + "identifierRequired": "请输入邮箱或用户名", "passwordRequired": "请输入密码", "confirmPasswordRequired": "请确认密码", "loading": "加载中..." diff --git a/prisma/migrations/20260203113111_set_username_unique/migration.sql b/prisma/migrations/20260203113111_set_username_unique/migration.sql new file mode 100644 index 0000000..1e854fa --- /dev/null +++ b/prisma/migrations/20260203113111_set_username_unique/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0eb2714..56ef5db 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,3 +1,4 @@ + generator client { provider = "prisma-client" output = "../generated/prisma" @@ -21,8 +22,12 @@ model User { dictionaryLookUps DictionaryLookUp[] translationHistories TranslationHistory[] + username String? + displayUsername String? + @@unique([email]) @@map("user") + @@unique([username]) } model Session { diff --git a/src/app/auth/AuthForm.tsx b/src/app/auth/AuthForm.tsx index 7dfc380..fcb3f4b 100644 --- a/src/app/auth/AuthForm.tsx +++ b/src/app/auth/AuthForm.tsx @@ -6,7 +6,7 @@ import { Container } from "@/components/ui/Container"; import { Input } from "@/components/ui/Input"; import { LightButton } from "@/components/ui/buttons"; 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 { redirectTo?: string; @@ -19,22 +19,22 @@ export function AuthForm({ redirectTo }: AuthFormProps) { const [clearSignUp, setClearSignUp] = useState(false); const [signInState, signInActionForm, isSignInPending] = useActionState( - async (prevState: SignUpState | undefined, formData: FormData) => { + async (_prevState: ActionOutputAuth | undefined, formData: FormData) => { if (clearSignIn) { setClearSignIn(false); return undefined; } - return signInAction(prevState || {}, formData); + return actionSignIn(undefined, formData); }, undefined ); const [signUpState, signUpActionForm, isSignUpPending] = useActionState( - async (prevState: SignUpState | undefined, formData: FormData) => { + async (_prevState: ActionOutputAuth | undefined, formData: FormData) => { if (clearSignUp) { setClearSignUp(false); return undefined; } - return signUpAction(prevState || {}, formData); + return actionSignUp(undefined, formData); }, undefined ); @@ -44,15 +44,32 @@ export function AuthForm({ redirectTo }: AuthFormProps) { const validateForm = (formData: FormData): boolean => { const newErrors: Record = {}; + const identifier = formData.get("identifier") as string; const email = formData.get("email") as string; + const username = formData.get("username") as string; const password = formData.get("password") as string; - const name = formData.get("name") as string; const confirmPassword = formData.get("confirmPassword") as string; - if (!email) { - newErrors.email = t("emailRequired"); - } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - newErrors.email = t("invalidEmail"); + // 登录模式验证 + if (mode === 'signin') { + if (!identifier) { + 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) { @@ -62,10 +79,6 @@ export function AuthForm({ redirectTo }: AuthFormProps) { } if (mode === 'signup') { - if (!name) { - newErrors.name = t("nameRequired"); - } - if (!confirmPassword) { newErrors.confirmPassword = t("confirmPasswordRequired"); } else if (password !== confirmPassword) { @@ -128,41 +141,57 @@ export function AuthForm({ redirectTo }: AuthFormProps) { {/* 登录/注册表单 */}
- {/* 用户名输入(仅注册模式显示) */} - {mode === 'signup' && ( + {/* 邮箱/用户名输入(登录模式)或 用户名输入(注册模式) */} + {mode === 'signin' ? (
- {/* 客户端验证错误 */} - {errors.name && ( -

{errors.name}

+ {errors.identifier && ( +

{errors.identifier}

)} - {/* 服务器端验证错误 */} - {currentError?.errors?.username && ( -

{currentError.errors.username[0]}

+ {currentError?.errors?.email && ( +

{currentError.errors.email[0]}

)}
- )} + ) : ( + <> + {/* 用户名输入(仅注册模式) */} +
+ + {errors.username && ( +

{errors.username}

+ )} + {currentError?.errors?.username && ( +

{currentError.errors.username[0]}

+ )} +
- {/* 邮箱输入 */} -
- - {errors.email && ( -

{errors.email}

- )} - {currentError?.errors?.email && ( -

{currentError.errors.email[0]}

- )} -
+ {/* 邮箱输入(仅注册模式) */} +
+ + {errors.email && ( +

{errors.email}

+ )} + {currentError?.errors?.email && ( +

{currentError.errors.email[0]}

+ )} +
+ + )} {/* 密码输入 */}
@@ -256,4 +285,4 @@ export function AuthForm({ redirectTo }: AuthFormProps) {
); -} \ No newline at end of file +} diff --git a/src/app/users/[username]/page.ts b/src/app/users/[username]/page.ts new file mode 100644 index 0000000..e286ee4 --- /dev/null +++ b/src/app/users/[username]/page.ts @@ -0,0 +1,8 @@ +interface UserPageProps { + params: Promise<{ username: string}>; +} + +export default async function UserPage({params}: UserPageProps) { + const {username} = await params; + +} diff --git a/src/auth.ts b/src/auth.ts index dbf458c..91f6daa 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,6 +2,7 @@ import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { nextCookies } from "better-auth/next-js"; import { prisma } from "./lib/db"; +import { username } from "better-auth/plugins"; export const auth = betterAuth({ database: prismaAdapter(prisma, { @@ -16,5 +17,5 @@ export const auth = betterAuth({ clientSecret: process.env.GITHUB_CLIENT_SECRET as string }, }, - plugins: [nextCookies()] + plugins: [nextCookies(), username()] }); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 4cdaf62..2e364ba 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,5 +1,9 @@ +import { usernameClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ - baseURL: process.env.BETTER_AUTH_URL as string + baseURL: process.env.BETTER_AUTH_URL as string, + plugins: [ + usernameClient() + ] }); diff --git a/src/modules/auth/auth-action-dto.ts b/src/modules/auth/auth-action-dto.ts new file mode 100644 index 0000000..f50d61a --- /dev/null +++ b/src/modules/auth/auth-action-dto.ts @@ -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; + +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; + +export const validateActionInputSignIn = generateValidator(schemaActionInputSignIn); + +// Schema for sign out +const schemaActionInputSignOut = z.object({ + redirectTo: z.string().nullish(), +}); + +export type ActionInputSignOut = z.infer; + +export const validateActionInputSignOut = generateValidator(schemaActionInputSignOut); + +// Output types +export type ActionOutputAuth = { + success: boolean; + message: string; + errors?: { + username?: string[]; + email?: string[]; + password?: string[]; + identifier?: string[]; + }; +}; diff --git a/src/modules/auth/auth-action.ts b/src/modules/auth/auth-action.ts index 7712fc4..dbe72c0 100644 --- a/src/modules/auth/auth-action.ts +++ b/src/modules/auth/auth-action.ts @@ -3,131 +3,144 @@ import { auth } from "@/auth"; import { headers } from "next/headers"; 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 { - username: string; - 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 - }; - } +// Re-export types for use in components +export type { ActionOutputAuth } from "./auth-action-dto"; +/** + * Sign up action + * Creates a new user account + */ +export async function actionSignUp(prevState: ActionOutputAuth | undefined, formData: FormData): Promise { try { - await auth.api.signUpEmail({ - body: { - email, - password, - name - } + // Extract form data + const rawData = { + email: formData.get("email") as string, + username: formData.get("username") as string, + 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 || "/"); - } catch (error) { - if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) { - throw error; + if (!result.success) { + return { + success: false, + 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: "注册失败,请稍后再试" + message: "Registration failed. Please try again later.", }; } } -export async function signInAction(prevState: SignUpState, formData: FormData) { - const email = formData.get("email") 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 (!password) { - errors.password = ["密码是必填项"]; - } - - // 如果有验证错误,返回错误状态 - if (Object.keys(errors).length > 0) { - return { - success: false, - message: "请修正表单中的错误", - errors - }; - } - +/** + * Sign in action + * Authenticates a user + */ +export async function actionSignIn(_prevState: ActionOutputAuth | undefined, formData: FormData): Promise { try { - await auth.api.signInEmail({ - body: { - email, - password, - } + // Extract form data + const rawData = { + identifier: formData.get("identifier") as string, + 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 || "/"); - } catch (error) { - if (error instanceof Error && error.message.includes('NEXT_REDIRECT')) { - throw error; + if (!result.success) { + return { + success: false, + 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: "登录失败,请检查您的邮箱和密码" + message: "Sign in failed. Please check your credentials.", }; } } +/** + * Sign out action + * Signs out the current user + */ export async function signOutAction() { - await auth.api.signOut({ - headers: await headers() - }); + try { + 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"); + } } diff --git a/src/modules/auth/auth-service-dto.ts b/src/modules/auth/auth-service-dto.ts new file mode 100644 index 0000000..c7caf01 --- /dev/null +++ b/src/modules/auth/auth-service-dto.ts @@ -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; +}; diff --git a/src/modules/auth/auth-service.ts b/src/modules/auth/auth-service.ts new file mode 100644 index 0000000..dfecbcb --- /dev/null +++ b/src/modules/auth/auth-service.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/src/shared/constant.ts b/src/shared/constant.ts index 90d0fc2..0915727 100644 --- a/src/shared/constant.ts +++ b/src/shared/constant.ts @@ -14,4 +14,9 @@ export const LENGTH_MAX_FOLDER_NAME = 20; export const LENGTH_MIN_FOLDER_NAME = 1; export const LENGTH_MAX_TRANSLATOR_TEXT = 1000; -export const LENGTH_MIN_TRANSLATOR_TEXT = 1; \ No newline at end of file +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; \ No newline at end of file