Commit 7b583fbe authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/login-password-visibility-remember-email' into 'develop'

Feat/login password visibility remember email

See merge request !4
parents 4e0a8443 32651e69
......@@ -3,11 +3,14 @@ import React from "react";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
endAdornment?: React.ReactNode;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = "", id, ...props }, ref) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
({ label, error, endAdornment, className = "", id, ...props }, ref) => {
const generatedId = React.useId();
const inputId = id || generatedId;
const errorId = `${inputId}-error`;
return (
<div className="flex flex-col gap-2 w-full">
......@@ -16,22 +19,32 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
{label}
</label>
)}
<input
id={inputId}
ref={ref}
className={`
bg-clay-bg text-clay-text font-nunito text-base px-4 py-3
rounded-clay-sm shadow-clay-pressed border border-transparent
transition-all duration-150 ease-in-out placeholder-clay-text-muted/65
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
disabled:opacity-60 disabled:cursor-not-allowed
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className}
`}
{...props}
/>
<div className="relative w-full">
<input
id={inputId}
ref={ref}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={`
w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3
rounded-clay-sm shadow-clay-pressed border border-transparent
transition-all duration-200 ease-in-out placeholder-clay-text-muted/65
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
disabled:opacity-60 disabled:cursor-not-allowed
${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 && (
<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}
</span>
)}
......
......@@ -162,6 +162,22 @@ export const ChevronRightIcon: React.FC<IconProps> = ({ size = 18, ...props }) =
</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.)
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}>
......
......@@ -8,11 +8,35 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
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({
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"),
rememberMe: z.boolean(),
});
type LoginFormValues = z.infer<typeof loginSchema>;
......@@ -22,6 +46,8 @@ const LoginPage: React.FC = () => {
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const [isLoading, setIsLoading] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [rememberedEmail] = useState(getRememberedEmail);
const {
register,
......@@ -29,13 +55,22 @@ const LoginPage: React.FC = () => {
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: rememberedEmail,
password: "",
rememberMe: Boolean(rememberedEmail),
},
});
const onSubmit = async (values: LoginFormValues) => {
setIsLoading(true);
try {
const response = await authService.login(values);
const response = await authService.login({
email: values.email,
password: values.password,
});
if (response.success && response.data) {
updateRememberedEmail(values.email, values.rememberMe);
setAuth(response.data.user, "", "");
openSnackbar({
type: "success",
......@@ -48,10 +83,10 @@ const LoginPage: React.FC = () => {
text: response.message || "Đăng nhập thất bại",
});
}
} catch (error: any) {
} catch (error: unknown) {
openSnackbar({
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 {
setIsLoading(false);
......@@ -75,6 +110,7 @@ const LoginPage: React.FC = () => {
label="Email"
placeholder="user@gmail.com"
type="email"
autoComplete="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
......@@ -83,13 +119,35 @@ const LoginPage: React.FC = () => {
<Input
label="Mật khẩu"
placeholder="••••••••"
type="password"
type={isPasswordVisible ? "text" : "password"}
autoComplete="current-password"
error={errors.password?.message}
{...register("password")}
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
onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer"
......
......@@ -234,7 +234,7 @@ const ProfilePage: React.FC = () => {
<Input
label="Số điện thoại"
placeholder="Nhập số điện thoại (ví dụ: 0912345678)..."
placeholder="Nhập số điện thoại..."
error={profileErrors.phoneNumber?.message}
{...registerProfile("phoneNumber")}
disabled={updateProfileMutation.isPending}
......
import { apiClient } from "@/lib/api-client";
import { ApiResponse, User, Session } from "@/types/auth";
import { ApiResponse, LoginRequest, User, Session } from "@/types/auth";
export const authService = {
async register(data: any): Promise<ApiResponse> {
......@@ -12,7 +12,7 @@ export const authService = {
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);
return response.data;
},
......
......@@ -21,6 +21,11 @@ export interface TokenPair {
refreshToken: string;
}
export interface LoginRequest {
email: string;
password: string;
}
export interface Session {
id: 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