Commit 6b3dbdaf authored by ThinhNC's avatar ThinhNC

feat(auth): add password visibility and remember email

parent 24913d00
...@@ -3,11 +3,14 @@ import React from "react"; ...@@ -3,11 +3,14 @@ import React from "react";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string; label?: string;
error?: string; error?: string;
endAdornment?: React.ReactNode;
} }
export const Input = React.forwardRef<HTMLInputElement, InputProps>( export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = "", id, ...props }, ref) => { ({ label, error, endAdornment, className = "", id, ...props }, ref) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`; const generatedId = React.useId();
const inputId = id || generatedId;
const errorId = `${inputId}-error`;
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
...@@ -16,22 +19,32 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>( ...@@ -16,22 +19,32 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
{label} {label}
</label> </label>
)} )}
<input <div className="relative w-full">
id={inputId} <input
ref={ref} id={inputId}
className={` ref={ref}
bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 aria-invalid={error ? true : undefined}
rounded-clay-sm shadow-clay-pressed border border-transparent aria-describedby={error ? errorId : undefined}
transition-all duration-150 ease-in-out placeholder-clay-text-muted/65 className={`
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20 w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3
disabled:opacity-60 disabled:cursor-not-allowed rounded-clay-sm shadow-clay-pressed border border-transparent
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""} transition-all duration-200 ease-in-out placeholder-clay-text-muted/65
${className} focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
`} disabled:opacity-60 disabled:cursor-not-allowed
{...props} ${endAdornment ? "pr-12" : ""}
/> ${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className}
`}
{...props}
/>
{endAdornment && (
<div className="absolute inset-y-0 right-3 flex items-center">
{endAdornment}
</div>
)}
</div>
{error && ( {error && (
<span className="font-nunito text-xs text-clay-expense px-1"> <span id={errorId} className="font-nunito text-xs text-clay-expense px-1">
{error} {error}
</span> </span>
)} )}
......
...@@ -162,6 +162,22 @@ export const ChevronRightIcon: React.FC<IconProps> = ({ size = 18, ...props }) = ...@@ -162,6 +162,22 @@ export const ChevronRightIcon: React.FC<IconProps> = ({ size = 18, ...props }) =
</svg> </svg>
); );
// Password visibility
export const EyeIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z" />
<circle cx="12" cy="12" r="3" fill="url(#clay-grad-primary)" />
</svg>
);
export const EyeOffIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M3 3l18 18" />
<path d="M10.6 6.2A10.8 10.8 0 0 1 12 6c6.5 0 10 6 10 6a16.2 16.2 0 0 1-2.1 2.8M6.2 6.2C3.5 8 2 12 2 12s3.5 6 10 6c1.8 0 3.4-.5 4.7-1.2" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
</svg>
);
// Income Category Icon Example (Food, Shopping, etc.) // Income Category Icon Example (Food, Shopping, etc.)
export const FoodIcon: React.FC<IconProps> = ({ size = 24, ...props }) => ( export const FoodIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}> <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
......
...@@ -8,11 +8,35 @@ import { Card } from "@/components/ui/Card"; ...@@ -8,11 +8,35 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { IconGradients } from "@/components/ui/icons"; import { EyeIcon, EyeOffIcon, IconGradients } from "@/components/ui/icons";
import { getErrorMessage } from "@/lib/error-message";
const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail";
const getRememberedEmail = (): string => {
try {
return localStorage.getItem(REMEMBERED_EMAIL_KEY) || "";
} catch {
return "";
}
};
const updateRememberedEmail = (email: string, shouldRemember: boolean): void => {
try {
if (shouldRemember) {
localStorage.setItem(REMEMBERED_EMAIL_KEY, email);
} else {
localStorage.removeItem(REMEMBERED_EMAIL_KEY);
}
} catch {
// Storage can be unavailable in restricted webviews; login should still succeed.
}
};
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().min(1, "Email không được để trống").email("Email không hợp lệ"), email: z.string().min(1, "Email không được để trống").email("Email không hợp lệ"),
password: z.string().min(8, "Mật khẩu phải từ 8 ký tự trở lên"), password: z.string().min(8, "Mật khẩu phải từ 8 ký tự trở lên"),
rememberMe: z.boolean(),
}); });
type LoginFormValues = z.infer<typeof loginSchema>; type LoginFormValues = z.infer<typeof loginSchema>;
...@@ -22,6 +46,8 @@ const LoginPage: React.FC = () => { ...@@ -22,6 +46,8 @@ const LoginPage: React.FC = () => {
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth); const setAuth = useAuthStore((state) => state.setAuth);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [rememberedEmail] = useState(getRememberedEmail);
const { const {
register, register,
...@@ -29,13 +55,22 @@ const LoginPage: React.FC = () => { ...@@ -29,13 +55,22 @@ const LoginPage: React.FC = () => {
formState: { errors }, formState: { errors },
} = useForm<LoginFormValues>({ } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema), resolver: zodResolver(loginSchema),
defaultValues: {
email: rememberedEmail,
password: "",
rememberMe: Boolean(rememberedEmail),
},
}); });
const onSubmit = async (values: LoginFormValues) => { const onSubmit = async (values: LoginFormValues) => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await authService.login(values); const response = await authService.login({
email: values.email,
password: values.password,
});
if (response.success && response.data) { if (response.success && response.data) {
updateRememberedEmail(values.email, values.rememberMe);
setAuth(response.data.user, "", ""); setAuth(response.data.user, "", "");
openSnackbar({ openSnackbar({
type: "success", type: "success",
...@@ -48,10 +83,10 @@ const LoginPage: React.FC = () => { ...@@ -48,10 +83,10 @@ const LoginPage: React.FC = () => {
text: response.message || "Đăng nhập thất bại", text: response.message || "Đăng nhập thất bại",
}); });
} }
} catch (error: any) { } catch (error: unknown) {
openSnackbar({ openSnackbar({
type: "error", type: "error",
text: error.response?.data?.message || "Đăng nhập thất bại, vui lòng kiểm tra lại thông tin.", text: getErrorMessage(error, "Đăng nhập thất bại, vui lòng kiểm tra lại thông tin."),
}); });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
...@@ -75,6 +110,7 @@ const LoginPage: React.FC = () => { ...@@ -75,6 +110,7 @@ const LoginPage: React.FC = () => {
label="Email" label="Email"
placeholder="user@gmail.com" placeholder="user@gmail.com"
type="email" type="email"
autoComplete="email"
error={errors.email?.message} error={errors.email?.message}
{...register("email")} {...register("email")}
disabled={isLoading} disabled={isLoading}
...@@ -83,13 +119,35 @@ const LoginPage: React.FC = () => { ...@@ -83,13 +119,35 @@ const LoginPage: React.FC = () => {
<Input <Input
label="Mật khẩu" label="Mật khẩu"
placeholder="••••••••" placeholder="••••••••"
type="password" type={isPasswordVisible ? "text" : "password"}
autoComplete="current-password"
error={errors.password?.message} error={errors.password?.message}
{...register("password")} {...register("password")}
disabled={isLoading} disabled={isLoading}
endAdornment={
<button
type="button"
aria-label={isPasswordVisible ? "Ẩn mật khẩu" : "Hiện mật khẩu"}
aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((visible) => !visible)}
disabled={isLoading}
className="flex h-8 w-8 items-center justify-center rounded-full text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
</button>
}
/> />
<div className="text-right"> <div className="flex items-center justify-between gap-3 px-1">
<label className="flex cursor-pointer items-center gap-2 font-nunito text-xs font-semibold text-clay-text-muted">
<input
type="checkbox"
{...register("rememberMe")}
disabled={isLoading}
className="h-4 w-4 cursor-pointer rounded border-clay-text-muted accent-clay-primary transition-all duration-200 ease-in-out focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
/>
<span>Ghi nhớ email</span>
</label>
<span <span
onClick={() => navigate("/forgot-password")} onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer" className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer"
......
import { apiClient } from "@/lib/api-client"; import { apiClient } from "@/lib/api-client";
import { ApiResponse, User, Session } from "@/types/auth"; import { ApiResponse, LoginRequest, User, Session } from "@/types/auth";
export const authService = { export const authService = {
async register(data: any): Promise<ApiResponse> { async register(data: any): Promise<ApiResponse> {
...@@ -12,7 +12,7 @@ export const authService = { ...@@ -12,7 +12,7 @@ export const authService = {
return response.data; return response.data;
}, },
async login(data: any): Promise<ApiResponse<{ user: User }>> { async login(data: LoginRequest): Promise<ApiResponse<{ user: User }>> {
const response = await apiClient.post("/auth/login", data); const response = await apiClient.post("/auth/login", data);
return response.data; return response.data;
}, },
......
...@@ -21,6 +21,11 @@ export interface TokenPair { ...@@ -21,6 +21,11 @@ export interface TokenPair {
refreshToken: string; refreshToken: string;
} }
export interface LoginRequest {
email: string;
password: string;
}
export interface Session { export interface Session {
id: string; id: string;
deviceName: string; deviceName: string;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment