Yimaru-Admin/src/pages/auth/LoginPage.tsx

421 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, useCallback } from "react";
import { Link, Navigate, useNavigate } from "react-router-dom";
import { Eye, EyeOff } from "lucide-react";
import { BrandLogo } from "../../components/brand/BrandLogo";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { login, loginWithGoogle } from "../../api/auth.api";
import type { LoginRequest } from "../../types/auth.types";
import type { LoginResult } from "../../api/auth.api";
import { toast } from "sonner";
declare global {
interface Window {
google?: {
accounts: {
id: {
initialize: (config: {
client_id: string;
callback: (response: { credential: string }) => void;
auto_select?: boolean;
}) => void;
renderButton: (
element: HTMLElement,
config: {
theme?: string;
size?: string;
width?: number;
text?: string;
shape?: string;
logo_alignment?: string;
}
) => void;
};
};
};
}
}
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18A10.96 10.96 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l3.66-2.84Z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z"
fill="#EA4335"
/>
</svg>
);
}
export function LoginPage() {
const navigate = useNavigate();
const token = localStorage.getItem("access_token");
if (token) {
return <Navigate to="/dashboard" replace />;
}
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [googleReady, setGoogleReady] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
const googleBtnRef = useRef<HTMLDivElement>(null);
const storeTokensAndRedirect = useCallback(
(result: LoginResult) => {
localStorage.setItem("access_token", result.accessToken);
localStorage.setItem("refresh_token", result.refreshToken);
localStorage.setItem("role", result.role);
localStorage.setItem("member_id", result.memberId.toString());
toast.success("Welcome back!", {
description: "You have signed in successfully.",
});
navigate("/dashboard");
},
[navigate]
);
const handleGoogleCallback = useCallback(
async (response: { credential: string }) => {
setError(null);
setGoogleLoading(true);
try {
const result = await loginWithGoogle(response.credential);
storeTokensAndRedirect(result);
} catch (err: any) {
setError(
err.response?.data?.message || "Google sign-in failed. Please try again."
);
} finally {
setGoogleLoading(false);
}
},
[storeTokensAndRedirect]
);
// Load Google Identity Services script
useEffect(() => {
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
if (!clientId) return;
// If already loaded
if (window.google?.accounts) {
setGoogleReady(true);
return;
}
const script = document.createElement("script");
script.src = "https://accounts.google.com/gsi/client";
script.async = true;
script.defer = true;
script.onload = () => setGoogleReady(true);
document.head.appendChild(script);
return () => {
// Cleanup only if we added it
if (document.head.contains(script)) {
document.head.removeChild(script);
}
};
}, []);
// Initialize Google button once ready
useEffect(() => {
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
if (!googleReady || !clientId || !window.google?.accounts) return;
window.google.accounts.id.initialize({
client_id: clientId,
callback: handleGoogleCallback,
});
if (googleBtnRef.current) {
// Render the hidden native button (we use our own styled button)
window.google.accounts.id.renderButton(googleBtnRef.current, {
theme: "outline",
size: "large",
width: 400,
text: "signin_with",
shape: "rectangular",
});
}
}, [googleReady, handleGoogleCallback]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const errors: { email?: string; password?: string } = {};
if (!email.trim()) errors.email = "Please enter your email address";
if (!password) errors.password = "Please enter your password";
if (Object.keys(errors).length > 0) {
setFieldErrors(errors);
return;
}
setFieldErrors({});
setError(null);
setLoading(true);
const payload: LoginRequest = { email, password };
try {
const res: LoginResult = await login(payload);
storeTokensAndRedirect(res);
} catch (err: any) {
setError(err.response?.data?.message || "Invalid email or password");
} finally {
setLoading(false);
}
};
const handleGoogleClick = () => {
// Click the hidden Google button to trigger the native popup
const iframe = googleBtnRef.current?.querySelector("iframe");
const innerBtn =
googleBtnRef.current?.querySelector('[role="button"]') as HTMLElement | null;
if (innerBtn) {
innerBtn.click();
} else if (iframe) {
// Fallback: the native button may render as an iframe
iframe.click();
}
};
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
return (
<div className="relative flex min-h-screen overflow-hidden">
{/* Decorative left panel */}
<div className="hidden lg:flex lg:w-1/2 xl:w-[55%] items-center justify-center bg-gradient-to-br from-brand-600 via-brand-500 to-brand-400 relative">
{/* Abstract decorative shapes */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
<div className="absolute left-1/3 top-1/4 h-64 w-64 rounded-full bg-white/5" />
<div className="absolute bottom-1/4 left-1/4 h-48 w-48 rotate-45 rounded-3xl bg-white/[0.03]" />
</div>
<div className="relative z-10 max-w-md px-12 text-center">
{/* Large brand icon */}
<div className="mx-auto mb-8 grid h-20 w-20 place-items-center rounded-2xl bg-white/15 shadow-lg backdrop-blur-sm">
<div className="h-9 w-9 rotate-45 rounded-lg bg-white/90" />
</div>
<h2 className="mb-4 text-3xl font-bold tracking-tight text-white">
Yimaru Academy
</h2>
<p className="text-base leading-relaxed text-white/70">
Manage your academy, track student progress, and streamline
operations all from one powerful dashboard.
</p>
</div>
</div>
{/* Right panel login form */}
<div className="flex w-full flex-col items-center justify-center bg-white px-6 py-12 lg:w-1/2 xl:w-[45%]">
<div className="w-full max-w-[420px]">
{/* Mobile-only logo */}
<div className="mb-10 flex justify-center lg:hidden">
<BrandLogo />
</div>
{/* Header */}
<div className="mb-10">
<p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-400">
Admin Portal
</p>
<h1 className="mb-2 text-3xl font-bold tracking-tight text-grayScale-600">
Welcome back
</h1>
<p className="text-sm leading-relaxed text-grayScale-400">
Sign in to your account to continue
</p>
</div>
{/* Error */}
{error && (
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3.5 text-sm text-red-600">
<svg
className="mt-0.5 h-4 w-4 shrink-0"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clipRule="evenodd"
/>
</svg>
{error}
</div>
)}
{/* Google Sign-In Button */}
{googleClientId && (
<>
{/* Hidden native Google button */}
<div
ref={googleBtnRef}
className="absolute h-0 w-0 overflow-hidden opacity-0"
aria-hidden="true"
/>
<button
type="button"
onClick={handleGoogleClick}
disabled={googleLoading || !googleReady}
className="group mb-6 flex w-full items-center justify-center gap-3 rounded-xl border border-grayScale-200 bg-white px-4 py-3 text-sm font-medium text-grayScale-600 transition-all duration-200 hover:border-grayScale-300 hover:bg-grayScale-100 hover:shadow-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{googleLoading ? (
<svg
className="h-5 w-5 animate-spin text-grayScale-400"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<GoogleIcon className="h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
)}
{googleLoading ? "Signing in…" : "Continue with Google"}
</button>
{/* Divider */}
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-grayScale-200" />
</div>
<div className="relative flex justify-center text-xs font-medium uppercase tracking-wider">
<span className="bg-white px-4 text-grayScale-400">
or sign in with email
</span>
</div>
</div>
</>
)}
<form onSubmit={handleSubmit} className="space-y-5" autoComplete="on" method="post" noValidate>
{/* Email */}
<div>
<label
htmlFor="email"
className="mb-1.5 block text-sm font-medium text-grayScale-600"
>
Email address
</label>
<Input
id="email"
name="email"
type="email"
placeholder="you@example.com"
autoComplete="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }));
}}
className={`h-11 rounded-xl ${fieldErrors.email ? "border-red-400 focus-visible:ring-red-400/40" : ""}`}
/>
{fieldErrors.email && (
<p className="mt-1.5 text-xs text-red-500">{fieldErrors.email}</p>
)}
</div>
{/* Password */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<label
htmlFor="password"
className="block text-sm font-medium text-grayScale-600"
>
Password
</label>
<Link
to="/forgot-password"
className="text-xs font-medium text-brand-500 transition-colors hover:text-brand-600"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Input
id="password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
autoComplete="current-password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }));
}}
className={`h-11 rounded-xl pr-10 ${fieldErrors.password ? "border-red-400 focus-visible:ring-red-400/40" : ""}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-grayScale-400 transition-colors hover:text-grayScale-600"
tabIndex={-1}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{fieldErrors.password && (
<p className="mt-1.5 text-xs text-red-500">{fieldErrors.password}</p>
)}
</div>
<Button
type="submit"
className="mt-2 h-11 w-full rounded-xl text-sm font-semibold tracking-wide"
disabled={loading}
>
{loading ? "Signing in…" : "Sign in"}
</Button>
</form>
{/* Footer */}
<div className="mt-10 text-center text-xs text-grayScale-400">
<p>© {new Date().getFullYear()} Yimaru Academy · All rights reserved</p>
<p className="mt-1 font-mono text-[10px] text-grayScale-300">
v{__BUILD_HASH__} · {new Date(__BUILD_TIME__).toLocaleDateString()}
</p>
</div>
</div>
</div>
</div>
);
}