ocdp v1
This commit is contained in:
265
frontend/src/features/auth/pages/AuthPage.tsx
Normal file
265
frontend/src/features/auth/pages/AuthPage.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { LogIn, UserPlus, Loader2, Eye, EyeOff } from "lucide-react";
|
||||
import { useToast } from "@/shared";
|
||||
import { getErrorMessage } from "@/shared/utils/handleApiError";
|
||||
import { login as apiLogin, register as apiRegister, type AuthResponse } from "@/api";
|
||||
|
||||
type Props = {
|
||||
onLogin: (response: AuthResponse) => void;
|
||||
};
|
||||
|
||||
const AuthPage: React.FC<Props> = ({ onLogin }) => {
|
||||
const navigate = useNavigate();
|
||||
const { success: toastSuccess, error: toastError, info: toastInfo } = useToast();
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<"login" | "register">("login");
|
||||
|
||||
// Login form
|
||||
const [loginUsername, setLoginUsername] = useState("");
|
||||
const [loginPassword, setLoginPassword] = useState("");
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
// Register form
|
||||
const [regUsername, setRegUsername] = useState("");
|
||||
const [regPassword, setRegPassword] = useState("");
|
||||
const [regConfirmPwd, setRegConfirmPwd] = useState("");
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [regLoading, setRegLoading] = useState(false);
|
||||
const [regError, setRegError] = useState<string | null>(null);
|
||||
|
||||
// Handle login
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!loginUsername || !loginPassword) return;
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
toastInfo("Logging in...", { title: "Login", durationMs: 1200 });
|
||||
|
||||
try {
|
||||
const response = await apiLogin({ username: loginUsername, password: loginPassword });
|
||||
|
||||
// JWT 格式: { access_token, refresh_token, username, ... }
|
||||
toastSuccess(`Welcome, ${response.username}!`);
|
||||
onLogin(response);
|
||||
navigate("/home", { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const raw = err as any;
|
||||
const msg = raw?.message?.includes("Failed to fetch")
|
||||
? "Network or CORS error: Please check backend CORS or use Vite proxy in development."
|
||||
: getErrorMessage(err, "Login failed. Please try again later.");
|
||||
|
||||
setLoginError(msg);
|
||||
toastError(msg);
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle register
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (regPassword !== regConfirmPwd) {
|
||||
const msg = "Passwords do not match";
|
||||
setRegError(msg);
|
||||
toastError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setRegLoading(true);
|
||||
setRegError(null);
|
||||
toastInfo("Registering...", { title: "Register", durationMs: 1200 });
|
||||
|
||||
try {
|
||||
const registerResponse = await apiRegister({ username: regUsername, password: regPassword });
|
||||
|
||||
toastSuccess(`Welcome, ${registerResponse.username}! Registration successful.`);
|
||||
toastInfo("Signing you in...", { title: "Auto Login", durationMs: 1200 });
|
||||
|
||||
try {
|
||||
const loginResponse = await apiLogin({ username: regUsername, password: regPassword });
|
||||
// JWT 格式: { access_token, refresh_token, username, ... }
|
||||
onLogin(loginResponse);
|
||||
navigate("/home", { replace: true });
|
||||
} catch (autoLoginErr: unknown) {
|
||||
const msg = getErrorMessage(autoLoginErr, "Registration succeeded but auto login failed. Please login manually.");
|
||||
setRegError(msg);
|
||||
toastError(msg);
|
||||
return;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const raw = err as any;
|
||||
let msg = getErrorMessage(err, "Registration failed. Please try again later.");
|
||||
|
||||
// 提供更友好的错误提示
|
||||
if (raw?.message?.includes("Failed to fetch")) {
|
||||
msg = "Network or CORS error: Please check backend CORS or use Vite proxy in development.";
|
||||
} else if (raw?.message?.includes("username")) {
|
||||
msg = "Username is already taken or invalid.";
|
||||
}
|
||||
|
||||
setRegError(msg);
|
||||
toastError(msg);
|
||||
console.error("Registration error:", err);
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-dark text-primary flex items-center justify-center px-4 sm:px-6">
|
||||
<div className="pointer-events-none absolute inset-0 bg-app-gradient opacity-90" aria-hidden="true" />
|
||||
<div className="relative w-full max-w-md p-6 sm:p-7 bg-dark-lighter/80 border border-dark-border/70 rounded-2xl shadow-soft backdrop-blur-xl">
|
||||
{/* Tab Header */}
|
||||
<div className="flex border-b border-dark-border/60 mb-6">
|
||||
<button
|
||||
className={`flex-1 py-3 text-center font-semibold transition-colors ${
|
||||
activeTab === "login"
|
||||
? "text-brand-accent border-b-2 border-brand-accent"
|
||||
: "text-secondary hover:text-primary"
|
||||
}`}
|
||||
onClick={() => setActiveTab("login")}
|
||||
>
|
||||
<LogIn className="w-5 h-5 inline-block mr-2" />
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 text-center font-semibold transition-colors ${
|
||||
activeTab === "register"
|
||||
? "text-accent-teal border-b-2 border-accent-teal"
|
||||
: "text-secondary hover:text-primary"
|
||||
}`}
|
||||
onClick={() => setActiveTab("register")}
|
||||
>
|
||||
<UserPlus className="w-5 h-5 inline-block mr-2" />
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
{activeTab === "login" && (
|
||||
<div className="animate-fadeIn">
|
||||
<header className="mb-6 text-center">
|
||||
<LogIn className="w-10 h-10 text-brand-accent mx-auto mb-2" />
|
||||
<h1 className="text-2xl font-semibold text-primary">Welcome</h1>
|
||||
<p className="text-secondary text-sm mt-1">Access your manager</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary">Username</label>
|
||||
<input
|
||||
value={loginUsername}
|
||||
onChange={(e) => setLoginUsername(e.target.value)}
|
||||
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-brand-accent focus:border-brand-accent focus:outline-none transition-shadow"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-secondary">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-brand-accent focus:border-brand-accent focus:outline-none transition-shadow"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginLoading}
|
||||
className={`w-full disabled:opacity-60 font-semibold py-2.5 rounded-lg flex items-center justify-center gap-2 transition-colors duration-150
|
||||
${loginLoading ? "bg-brand-accent/70 cursor-wait text-primary" : "bg-brand-accent text-dark hover:bg-brand-accent/90"}`}
|
||||
>
|
||||
{loginLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <LogIn className="w-4 h-4" />}
|
||||
{loginLoading ? "Logging in..." : "Login"}
|
||||
</button>
|
||||
|
||||
{loginError && <p className="text-red-400 text-center text-sm">{loginError}</p>}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Register Form */}
|
||||
{activeTab === "register" && (
|
||||
<div className="animate-fadeIn">
|
||||
<header className="mb-6 text-center">
|
||||
<UserPlus className="w-10 h-10 text-accent-teal mx-auto mb-2" />
|
||||
<h1 className="text-2xl font-semibold text-primary">Create Account</h1>
|
||||
<p className="text-secondary text-sm mt-1">Create a new account</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-secondary">Username</label>
|
||||
<input
|
||||
value={regUsername}
|
||||
onChange={(e) => setRegUsername(e.target.value)}
|
||||
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-accent-teal focus:border-accent-teal focus:outline-none transition-shadow"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-secondary">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPwd ? "text" : "password"}
|
||||
value={regPassword}
|
||||
onChange={(e) => setRegPassword(e.target.value)}
|
||||
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-accent-teal focus:border-accent-teal focus:outline-none transition-shadow pr-10"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPwd((s) => !s)}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-secondary hover:text-primary transition-colors"
|
||||
aria-label={showPwd ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPwd ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-secondary">Confirm Password</label>
|
||||
<input
|
||||
type={showPwd ? "text" : "password"}
|
||||
value={regConfirmPwd}
|
||||
onChange={(e) => setRegConfirmPwd(e.target.value)}
|
||||
className="mt-1 w-full bg-dark/60 border border-dark-border/60 rounded-lg p-2 text-primary focus:ring-2 focus:ring-accent-teal focus:border-accent-teal focus:outline-none transition-shadow"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={regLoading}
|
||||
className={`w-full disabled:opacity-60 font-semibold py-2.5 rounded-lg flex items-center justify-center gap-2 transition-colors duration-150
|
||||
${regLoading ? "bg-accent-teal/70 cursor-wait text-primary" : "bg-accent-teal text-dark hover:bg-accent-teal/90"}`}
|
||||
>
|
||||
{regLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserPlus className="w-4 h-4" />}
|
||||
{regLoading ? "Registering..." : "Register"}
|
||||
</button>
|
||||
|
||||
{regError && <p className="text-red-400 text-center text-sm">{regError}</p>}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthPage;
|
||||
Reference in New Issue
Block a user