简化登录

This commit is contained in:
2026-02-20 22:28:55 +08:00
parent 0149fde0bd
commit 9e9ac373c6
10 changed files with 176 additions and 346 deletions

View File

@@ -0,0 +1,66 @@
"use client";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
export default function LoginPage() {
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect");
const session = authClient.useSession().data;
const router = useRouter();
useEffect(() => {
if (session) {
router.push(redirectTo ?? "/profile");
}
});
function login() {
const username = (document.getElementById("username") as HTMLInputElement).value;
const password = (document.getElementById("password") as HTMLInputElement).value;
console.log(username, password);
if (username.includes("@")) {
authClient.signIn.email({
email: username,
password: username
});
} else {
authClient.signIn.username({
username: username,
password: password,
fetchOptions: {
onError: (ctx) => {
toast.error(ctx.error.message);
}
}
});
}
}
return (
<div className="flex justify-center items-center h-screen w-screen">
<div className="rounded shadow-lg w-96 flex flex-col py-4">
<h1 className="text-6xl m-16 text-center"></h1>
<input type="text"
id="username"
placeholder="用户名或邮箱地址"
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
<input type="password"
id="password"
placeholder="密码"
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
<button
onClick={login}
className="text-xl rounded shadow w-16 mx-auto p-2 my-4">
</button>
<Link href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")}
className="text-center text-blue-800"
></Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function LogoutPage(
props: {
searchParams: Promise<{ [key: string]: string | undefined; }>;
}
) {
const searchParams = await props.searchParams;
const redirectTo = props.searchParams ?? null;
const session = await auth.api.getSession({
headers: await headers()
});
if (session) {
await auth.api.signOut({
headers: await headers()
});
redirect("/login" + (redirectTo ? `?redirect=${redirectTo}` : ""));
} else {
redirect("/profile");
}
return (<></>);
}

View File

@@ -0,0 +1,13 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
export default async function ProfilePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/login?redirect=/profile");
}
redirect(`/users/${session.user.username}`);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { authClient } from "@/lib/auth-client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
export default function SignUpPage() {
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect");
const session = authClient.useSession().data;
const router = useRouter();
console.log(JSON.stringify({ re: redirectTo }));
useEffect(() => {
if (session) {
router.push(redirectTo ?? "/profile");
}
});
function login() {
const username = (document.getElementById("username") as HTMLInputElement).value;
const email = (document.getElementById("email") as HTMLInputElement).value;
const password = (document.getElementById("password") as HTMLInputElement).value;
authClient.signUp.email({
email: email,
name: username,
username: username,
password: password,
fetchOptions: {
onError: (ctx) => {
toast.error(ctx.error.message);
}
}
});
}
return (
<div className="flex justify-center items-center h-screen w-screen">
<div className="rounded shadow-lg w-96 flex flex-col py-4">
<h1 className="text-6xl m-16 text-center"></h1>
<input type="text"
id="username"
placeholder="用户名"
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
<input type="email"
id="email"
placeholder="邮箱地址"
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
<input type="password"
id="password"
placeholder="密码"
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
<button
onClick={login}
className="text-xl rounded shadow w-16 mx-auto p-2 my-4">
</button>
<Link href={"/login" + (redirectTo ? `?redirect=${redirectTo}` : "")}
className="text-center text-blue-800"
></Link>
</div>
</div>
);
}

View File

@@ -1,14 +1,14 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { LinkButton } from "@/design-system/base/button"; import { LightButton, LinkButton } from "@/design-system/base/button";
import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action"; import { actionGetUserProfileByUsername } from "@/modules/auth/auth-action";
import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository"; import { repoGetFoldersWithTotalPairsByUserId } from "@/modules/folder/folder-repository";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { LogoutButton } from "@/app/users/[username]/LogoutButton"; // import { LogoutButton } from "./LogoutButton";
interface UserPageProps { interface UserPageProps {
params: Promise<{ username: string; }>; params: Promise<{ username: string; }>;
@@ -42,7 +42,7 @@ export default async function UserPage({ params }: UserPageProps) {
<div className="bg-white rounded-lg shadow-md p-6 mb-6"> <div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div></div> <div></div>
{isOwnProfile && <LogoutButton />} {isOwnProfile && <LightButton></LightButton>}
</div> </div>
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
{/* Avatar */} {/* Avatar */}

View File

@@ -1,285 +0,0 @@
"use client";
import { useState, useActionState, startTransition } from "react";
import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout";
import { Input } from "@/design-system/base/input";
import { LightButton, LinkButton } from "@/design-system/base/button";
import { authClient } from "@/lib/auth-client";
import { actionSignIn, actionSignUp, ActionOutputAuth } from "@/modules/auth/auth-action";
interface AuthFormProps {
redirectTo?: string;
}
export function AuthForm({ redirectTo }: AuthFormProps) {
const t = useTranslations("auth");
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [clearSignIn, setClearSignIn] = useState(false);
const [clearSignUp, setClearSignUp] = useState(false);
const [signInState, signInActionForm, isSignInPending] = useActionState(
async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
if (clearSignIn) {
setClearSignIn(false);
return undefined;
}
return actionSignIn(undefined, formData);
},
undefined
);
const [signUpState, signUpActionForm, isSignUpPending] = useActionState(
async (_prevState: ActionOutputAuth | undefined, formData: FormData) => {
if (clearSignUp) {
setClearSignUp(false);
return undefined;
}
return actionSignUp(undefined, formData);
},
undefined
);
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (formData: FormData): boolean => {
const newErrors: Record<string, string> = {};
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 confirmPassword = formData.get("confirmPassword") as string;
// 登录模式验证
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) {
newErrors.password = t("passwordRequired");
} else if (password.length < 8) {
newErrors.password = t("passwordTooShort");
}
if (mode === 'signup') {
if (!confirmPassword) {
newErrors.confirmPassword = t("confirmPasswordRequired");
} else if (password !== confirmPassword) {
newErrors.confirmPassword = t("passwordsNotMatch");
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// 基本客户端验证
if (!validateForm(formData)) {
return;
}
// 添加 redirectTo 到 formData
if (redirectTo) {
formData.append("redirectTo", redirectTo);
}
// 使用 startTransition 包装 action 调用
startTransition(() => {
// 根据模式调用相应的 action
if (mode === 'signin') {
signInActionForm(formData);
} else {
signUpActionForm(formData);
}
});
};
const handleGitHubSignIn = async () => {
await authClient.signIn.social({
provider: "github",
callbackURL: redirectTo || "/"
});
};
const currentError = mode === 'signin' ? signInState : signUpState;
return (
<PageLayout>
{/* 页面标题 */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-800 mb-2">{t(mode === 'signin' ? 'signIn' : 'signUp')}</h1>
</div>
{/* 服务器端错误提示 */}
{currentError?.message && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
{currentError.message}
</div>
)}
{/* 登录/注册表单 */}
<form onSubmit={handleFormSubmit} className="space-y-4">
{/* 邮箱/用户名输入(登录模式)或 用户名输入(注册模式) */}
{mode === 'signin' ? (
<div>
<Input
type="text"
name="identifier"
placeholder={t("emailOrUsername")}
className="w-full px-3 py-2"
/>
{errors.identifier && (
<p className="text-red-500 text-sm mt-1">{errors.identifier}</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 && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.username[0]}</p>
)}
</div>
{/* 邮箱输入(仅注册模式) */}
<div>
<Input
type="email"
name="email"
placeholder={t("email")}
className="w-full px-3 py-2"
/>
{errors.email && (
<p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
{currentError?.errors?.email && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.email[0]}</p>
)}
</div>
</>
)}
{/* 密码输入 */}
<div>
<Input
type="password"
name="password"
placeholder={t("password")}
className="w-full px-3 py-2"
/>
{errors.password && (
<p className="text-red-500 text-sm mt-1">{errors.password}</p>
)}
{currentError?.errors?.password && (
<p className="text-red-500 text-sm mt-1">{currentError.errors.password[0]}</p>
)}
</div>
{/* 确认密码输入(仅注册模式显示) */}
{mode === 'signup' && (
<div>
<Input
type="password"
name="confirmPassword"
placeholder={t("confirmPassword")}
className="w-full px-3 py-2"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-sm mt-1">{errors.confirmPassword}</p>
)}
</div>
)}
{/* 提交按钮 */}
<LightButton
type="submit"
className={`w-full py-2 ${isSignInPending || isSignUpPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isSignInPending || isSignUpPending
? t("loading")
: t(mode === 'signin' ? 'signInButton' : 'signUpButton')
}
</LightButton>
</form>
{/* 第三方登录区域 */}
<div className="mt-6">
{/* 分隔线 */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500"></span>
</div>
</div>
{/* GitHub 登录按钮 */}
<LightButton
onClick={handleGitHubSignIn}
className="w-full mt-4 py-2 flex items-center justify-center gap-2"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
{t(mode === 'signin' ? 'signInWithGitHub' : 'signUpWithGitHub')}
</LightButton>
</div>
{/* 模式切换链接 */}
<div className="mt-6 text-center">
<LinkButton
type="button"
onClick={() => {
setMode(mode === 'signin' ? 'signup' : 'signin');
setErrors({});
// 清除服务器端错误状态
if (mode === 'signin') {
setClearSignIn(true);
} else {
setClearSignUp(true);
}
}}
>
{mode === 'signin'
? `${t("noAccount")} ${t("signUp")}`
: `${t("hasAccount")} ${t("signIn")}`
}
</LinkButton>
</div>
</PageLayout>
);
}

View File

@@ -1,20 +0,0 @@
import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { AuthForm } from "./AuthForm";
export default async function AuthPage(
props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined; }>;
}
) {
const searchParams = await props.searchParams;
const redirectTo = searchParams.redirect as string | undefined;
const session = await auth.api.getSession({ headers: await headers() });
if (session) {
redirect(redirectTo || '/');
}
return <AuthForm redirectTo={redirectTo} />;
}

View File

@@ -1,16 +0,0 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
export default async function ProfilePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/auth?redirect=/profile");
}
// 已登录,跳转到用户资料页面
// 优先使用 username如果没有则使用 email
const username = (session.user.username as string) || (session.user.email as string);
redirect(`/users/${username}`);
}

View File

@@ -1,20 +0,0 @@
"use client";
import { LightButton } from "@/design-system/base/button";
import { authClient } from "@/lib/auth-client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const t = useTranslations("profile");
const router = useRouter();
return <LightButton onClick={async () => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
router.push("/auth?redirect=/profile");
}
}
});
}}> {t("logout")}</LightButton >;
}

View File

@@ -58,8 +58,8 @@ export async function Navbar() {
</GhostLightButton> </GhostLightButton>
</> </>
|| <> || <>
<GhostLightButton href="/auth" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton> <GhostLightButton href="/login" className="hidden! md:block!" size="md">{t("sign_in")}</GhostLightButton>
<GhostLightButton href="/auth" className="md:hidden! block!" size="md"> <GhostLightButton href="/login" className="md:hidden! block!" size="md">
<User size={20} /> <User size={20} />
</GhostLightButton> </GhostLightButton>
</>; </>;