Commit 060b0c04 authored by ThinhNC's avatar ThinhNC

feat(fe): implement authentication and user profile management

parent 8621dab9
...@@ -21,11 +21,17 @@ ...@@ -21,11 +21,17 @@
"build": "vite build" "build": "vite build"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.7.1",
"@tanstack/react-query": "^5.101.4",
"axios": "^1.19.0",
"jotai": "^2.12.1", "jotai": "^2.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.84.0",
"zmp-sdk": "latest", "zmp-sdk": "latest",
"zmp-ui": "latest" "zmp-ui": "latest",
"zod": "^4.4.3",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
......
This diff is collapsed.
import React, { useEffect } from "react";
import { getSystemInfo } from "zmp-sdk"; import { getSystemInfo } from "zmp-sdk";
import { import {
AnimationRoutes, AnimationRoutes,
...@@ -7,21 +8,69 @@ import { ...@@ -7,21 +8,69 @@ import {
ZMPRouter, ZMPRouter,
} from "zmp-ui"; } from "zmp-ui";
import { AppProps } from "zmp-ui/app"; import { AppProps } from "zmp-ui/app";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { AuthGuard } from "@/components/shared/AuthGuard";
import HomePage from "@/pages/index"; import HomePage from "@/pages/index";
import StyleGuidePage from "@/pages/style-guide"; import StyleGuidePage from "@/pages/style-guide";
import LoginPage from "@/pages/auth/login";
import RegisterPage from "@/pages/auth/register";
import ForgotPasswordPage from "@/pages/auth/forgot-password";
import ResetPasswordPage from "@/pages/auth/reset-password";
import ProfilePage from "@/pages/profile/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
useEffect(() => {
const initAuth = async () => {
try {
const response = await authService.getMe();
if (response.success && response.data) {
// Access tokens are primarily managed via HTTP-only cookies in development/production
setAuth(response.data, "", "");
} else {
clearAuth();
}
} catch (error) {
clearAuth();
} finally {
setInitialized(true);
}
};
initAuth();
}, [setAuth, clearAuth, setInitialized]);
return <>{children}</>;
};
const Layout = () => { const Layout = () => {
return ( return (
<App theme={getSystemInfo().zaloTheme as AppProps["theme"]}> <App theme={getSystemInfo().zaloTheme as AppProps["theme"]}>
<SnackbarProvider> <QueryClientProvider client={queryClient}>
<ZMPRouter> <SnackbarProvider>
<AnimationRoutes> <ZMPRouter>
<Route path="/" element={<HomePage />}></Route> <AuthInitializer>
<Route path="/style-guide" element={<StyleGuidePage />}></Route> <AnimationRoutes>
</AnimationRoutes> {/* Public Auth Routes */}
</ZMPRouter> <Route path="/login" element={<LoginPage />}></Route>
</SnackbarProvider> <Route path="/register" element={<RegisterPage />}></Route>
<Route path="/forgot-password" element={<ForgotPasswordPage />}></Route>
<Route path="/reset-password" element={<ResetPasswordPage />}></Route>
{/* Private Protected Routes */}
<Route path="/" element={<AuthGuard><HomePage /></AuthGuard>}></Route>
<Route path="/profile" element={<AuthGuard><ProfilePage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes>
</AuthInitializer>
</ZMPRouter>
</SnackbarProvider>
</QueryClientProvider>
</App> </App>
); );
}; };
......
import React, { useEffect } from "react";
import { useNavigate } from "zmp-ui";
import { useAuthStore } from "@/stores/auth-store";
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isInitialized } = useAuthStore();
const navigate = useNavigate();
useEffect(() => {
if (isInitialized && !isAuthenticated) {
navigate("/login", { replace: true });
}
}, [isInitialized, isAuthenticated, navigate]);
if (!isInitialized) {
return (
<div className="page flex flex-col items-center justify-center min-h-screen">
<div className="w-16 h-16 border-4 border-clay-primary border-t-transparent rounded-full animate-spin shadow-clay-raised"></div>
<p className="clay-caption mt-4 animate-pulse text-clay-primary">Đang tải cấu hình...</p>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
};
...@@ -5,40 +5,39 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> ...@@ -5,40 +5,39 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
error?: string; error?: string;
} }
export const Input: React.FC<InputProps> = ({ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
label, ({ label, error, className = "", id, ...props }, ref) => {
error, const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
className = "",
id,
...props
}) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{label && ( {label && (
<label htmlFor={inputId} className="font-nunito font-semibold text-sm text-clay-text px-1"> <label htmlFor={inputId} className="font-nunito font-semibold text-sm text-clay-text px-1">
{label} {label}
</label> </label>
)} )}
<input <input
id={inputId} id={inputId}
className={` ref={ref}
bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 className={`
rounded-clay-sm shadow-clay-pressed border border-transparent bg-clay-bg text-clay-text font-nunito text-base px-4 py-3
transition-all duration-150 ease-in-out placeholder-clay-text-muted/65 rounded-clay-sm shadow-clay-pressed border border-transparent
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20 transition-all duration-150 ease-in-out placeholder-clay-text-muted/65
disabled:opacity-60 disabled:cursor-not-allowed focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""} disabled:opacity-60 disabled:cursor-not-allowed
${className} ${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
`} ${className}
{...props} `}
/> {...props}
{error && ( />
<span className="font-nunito text-xs text-clay-expense px-1"> {error && (
{error} <span className="font-nunito text-xs text-clay-expense px-1">
</span> {error}
)} </span>
</div> )}
); </div>
}; );
}
);
Input.displayName = "Input";
import axios from "axios";
import { useAuthStore } from "@/stores/auth-store";
// Create an Axios instance
export const apiClient = axios.create({
baseURL: "http://localhost:7777/api/v1",
timeout: 10000,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
// A separate instance for refreshing tokens to avoid interceptor loops
const refreshClient = axios.create({
baseURL: "http://localhost:7777/api/v1",
timeout: 10000,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor to attach bearer token
apiClient.interceptors.request.use(
(config) => {
const accessToken = useAuthStore.getState().accessToken || localStorage.getItem("accessToken");
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle token refresh
let isRefreshing = false;
let failedQueue: any[] = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// Check if the error is 401 and the request hasn't been retried yet
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// If already refreshing, wait for it to finish and then retry
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string | null) => {
if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
resolve(apiClient(originalRequest));
},
reject: (err: any) => {
reject(err);
},
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Try refreshing the token (cookies will be sent automatically)
const response = await refreshClient.post("/auth/refresh", {});
if (response.data?.success) {
const newData = response.data?.data;
let newAccessToken = null;
if (newData && newData.accessToken) {
newAccessToken = newData.accessToken;
useAuthStore.getState().setAuth(
useAuthStore.getState().user,
newAccessToken,
newData.refreshToken
);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
processQueue(null, newAccessToken);
isRefreshing = false;
return apiClient(originalRequest);
} else {
throw new Error("Refresh failed");
}
} catch (refreshError) {
processQueue(refreshError, null);
isRefreshing = false;
useAuthStore.getState().clearAuth();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
});
import React, { useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
const forgotPasswordSchema = z.object({
email: z.string().min(1, "Email không được để trống").email("Email không hợp lệ"),
});
type ForgotPasswordFormValues = z.infer<typeof forgotPasswordSchema>;
const ForgotPasswordPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const [isSent, setIsSent] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ForgotPasswordFormValues>({
resolver: zodResolver(forgotPasswordSchema),
});
const onSubmit = async (values: ForgotPasswordFormValues) => {
setIsLoading(true);
try {
const response = await authService.forgotPassword(values);
if (response.success) {
openSnackbar({
type: "success",
text: "Liên kết khôi phục mật khẩu đã được gửi đến email của bạn! ✉️",
});
setIsSent(true);
} else {
openSnackbar({
type: "error",
text: response.message || "Gửi yêu cầu thất bại",
});
}
} catch (error: any) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Gửi yêu cầu thất bại, vui lòng thử lại.",
});
} finally {
setIsLoading(false);
}
};
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Quên Mật Khẩu" showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Quên Mật Khẩu?</h1>
<p className="clay-caption">Đừng lo lắng, hãy nhập email của bạn để bắt đầu khôi phục</p>
</div>
{isSent ? (
<Card className="text-center space-y-4">
<div className="w-16 h-16 bg-clay-info/20 text-clay-info rounded-full flex items-center justify-center mx-auto shadow-clay-raised border border-white">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 className="clay-title-h3 text-clay-primary">Đã gửi Email thành công</h3>
<p className="clay-body text-sm">
Chúng tôi đã gửi hướng dẫn đặt lại mật khẩu đến email của bạn. Vui lòng kiểm tra hộp thư đến (và cả hộp thư rác).
</p>
<Button variant="primary" fullWidth onClick={() => navigate("/reset-password")}>
Nhập mã đặt lại mật khẩu
</Button>
</Card>
) : (
<>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Email liên kết"
placeholder="user@gmail.com"
type="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
/>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Đang gửi...</span>
</div>
) : (
"Gửi Yêu Cầu"
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Nhớ mật khẩu?{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng nhập
</span>
</div>
</>
)}
</div>
</Page>
);
};
export default ForgotPasswordPage;
import React, { useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
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";
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"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (values: LoginFormValues) => {
setIsLoading(true);
try {
const response = await authService.login(values);
if (response.success && response.data) {
setAuth(response.data.user, "", "");
openSnackbar({
type: "success",
text: "Đăng nhập thành công! 🎉",
});
navigate("/", { replace: true });
} else {
openSnackbar({
type: "error",
text: response.message || "Đăng nhập thất bại",
});
}
} catch (error: any) {
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.",
});
} finally {
setIsLoading(false);
}
};
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Đăng Nhập" showBackIcon={false} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Chào Mừng!</h1>
<p className="clay-caption">Đăng nhập tài khoản FinWise của bạn để bắt đầu</p>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Email"
placeholder="user@gmail.com"
type="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
/>
<Input
label="Mật khẩu"
placeholder="••••••••"
type="password"
error={errors.password?.message}
{...register("password")}
disabled={isLoading}
/>
<div className="text-right">
<span
onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer"
>
Quên mật khẩu?
</span>
</div>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Đang đăng nhập...</span>
</div>
) : (
"Đăng Nhập"
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Chưa có tài khoản?{" "}
<span
onClick={() => navigate("/register")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng ký ngay
</span>
</div>
</div>
</Page>
);
};
export default LoginPage;
import React, { useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
const registerSchema = z
.object({
fullName: z.string().min(2, "Họ và tên phải từ 2 ký tự trở lên"),
email: z
.string()
.min(1, "Email không được để trống")
.email("Email không hợp lệ")
.refine((val) => val.endsWith("@gmail.com"), {
message: "Chỉ chấp nhận địa chỉ email @gmail.com",
}),
password: z
.string()
.min(8, "Mật khẩu phải từ 8 ký tự trở lên")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Mật khẩu phải chứa ít nhất 1 chữ hoa, 1 chữ thường và 1 chữ số"
),
confirmPassword: z.string().min(1, "Vui lòng xác nhận mật khẩu"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Mật khẩu xác nhận không khớp",
path: ["confirmPassword"],
});
type RegisterFormValues = z.infer<typeof registerSchema>;
const RegisterPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (values: RegisterFormValues) => {
setIsLoading(true);
try {
const response = await authService.register({
fullName: values.fullName,
email: values.email,
password: values.password,
});
if (response.success) {
openSnackbar({
type: "success",
text: "Đăng ký thành công! Vui lòng kiểm tra email để kích hoạt.",
});
setIsRegistered(true);
} else {
openSnackbar({
type: "error",
text: response.message || "Đăng ký thất bại",
});
}
} catch (error: any) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Đăng ký thất bại, vui lòng thử lại.",
});
} finally {
setIsLoading(false);
}
};
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Đăng Ký" showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Tạo Tài Khoản</h1>
<p className="clay-caption">Khởi đầu hành trình quản lý tài chính thông minh</p>
</div>
{isRegistered ? (
<Card className="text-center space-y-4">
<div className="w-16 h-16 bg-clay-income/20 text-clay-income rounded-full flex items-center justify-center mx-auto shadow-clay-raised border border-white">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 19v-8.93a2 2 0 01.89-1.664l8-4.666a2 2 0 012.22 0l8 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-2.25-1.5a2 2 0 00-2.5 0l-2.25 1.5" />
</svg>
</div>
<h3 className="clay-title-h3 text-clay-primary">Kiểm tra Email của bạn</h3>
<p className="clay-body text-sm">
Chúng tôi đã gửi một liên kết kích hoạt đến email của bạn. Vui lòng nhấp vào liên kết để kích hoạt tài khoản trước khi đăng nhập.
</p>
<Button variant="primary" fullWidth onClick={() => navigate("/login")}>
Quay lại Đăng Nhập
</Button>
</Card>
) : (
<>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Họ và tên"
placeholder="Nguyễn Văn A"
type="text"
error={errors.fullName?.message}
{...register("fullName")}
disabled={isLoading}
/>
<Input
label="Email (chỉ nhận @gmail.com)"
placeholder="user@gmail.com"
type="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
/>
<Input
label="Mật khẩu"
placeholder="••••••••"
type="password"
error={errors.password?.message}
{...register("password")}
disabled={isLoading}
/>
<Input
label="Xác nhận mật khẩu"
placeholder="••••••••"
type="password"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
disabled={isLoading}
/>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Đang đăng ký...</span>
</div>
) : (
"Đăng Ký"
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Đã có tài khoản?{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng nhập
</span>
</div>
</>
)}
</div>
</Page>
);
};
export default RegisterPage;
import React, { useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
const resetPasswordSchema = z
.object({
token: z.string().uuid("Mã khôi phục không hợp lệ (phải ở định dạng UUID)"),
newPassword: z
.string()
.min(8, "Mật khẩu phải từ 8 ký tự trở lên")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Mật khẩu phải chứa ít nhất 1 chữ hoa, 1 chữ thường và 1 chữ số"
),
confirmPassword: z.string().min(1, "Vui lòng xác nhận mật khẩu"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Mật khẩu xác nhận không khớp",
path: ["confirmPassword"],
});
type ResetPasswordFormValues = z.infer<typeof resetPasswordSchema>;
const ResetPasswordPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetPasswordFormValues>({
resolver: zodResolver(resetPasswordSchema),
});
const onSubmit = async (values: ResetPasswordFormValues) => {
setIsLoading(true);
try {
const response = await authService.resetPassword({
token: values.token,
newPassword: values.newPassword,
});
if (response.success) {
openSnackbar({
type: "success",
text: "Đặt lại mật khẩu thành công! Bạn có thể đăng nhập ngay.",
});
navigate("/login", { replace: true });
} else {
openSnackbar({
type: "error",
text: response.message || "Đặt lại mật khẩu thất bại",
});
}
} catch (error: any) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Mã token không hợp lệ hoặc đã hết hạn.",
});
} finally {
setIsLoading(false);
}
};
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Đặt Lại Mật Khẩu" showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Đặt Lại Mật Khẩu</h1>
<p className="clay-caption">Nhập mã đặt lại nhận được trong email và mật khẩu mới của bạn</p>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Mã khôi phục (Token UUID)"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
type="text"
error={errors.token?.message}
{...register("token")}
disabled={isLoading}
/>
<Input
label="Mật khẩu mới"
placeholder="••••••••"
type="password"
error={errors.newPassword?.message}
{...register("newPassword")}
disabled={isLoading}
/>
<Input
label="Xác nhận mật khẩu mới"
placeholder="••••••••"
type="password"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
disabled={isLoading}
/>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Đang xử lý...</span>
</div>
) : (
"Xác Nhận Đổi Mật Khẩu"
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Quay lại{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng nhập
</span>
</div>
</div>
</Page>
);
};
export default ResetPasswordPage;
...@@ -3,10 +3,12 @@ import { Page, Header, useNavigate } from "zmp-ui"; ...@@ -3,10 +3,12 @@ import { Page, Header, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store";
import { AIAssistantIcon, IconGradients } from "@/components/ui/icons"; import { AIAssistantIcon, IconGradients } from "@/components/ui/icons";
function HomePage() { function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuthStore();
return ( return (
<Page className="page flex flex-col justify-between py-8"> <Page className="page flex flex-col justify-between py-8">
...@@ -16,7 +18,7 @@ function HomePage() { ...@@ -16,7 +18,7 @@ function HomePage() {
<div className="flex-1 flex flex-col items-center justify-center gap-6 px-4"> <div className="flex-1 flex flex-col items-center justify-center gap-6 px-4">
{/* Logo/Avatar Area */} {/* Logo/Avatar Area */}
<div className="relative"> <div className="relative">
<Avatar size="lg" className="border-clay-primary" /> <Avatar size="lg" src={user?.avatarUrl || ""} className="border-clay-primary shadow-clay-hover" />
<div className="absolute -bottom-2 -right-2 bg-clay-primary text-white p-2 rounded-full shadow-clay-raised border border-white"> <div className="absolute -bottom-2 -right-2 bg-clay-primary text-white p-2 rounded-full shadow-clay-raised border border-white">
<AIAssistantIcon size={20} /> <AIAssistantIcon size={20} />
</div> </div>
...@@ -24,27 +26,34 @@ function HomePage() { ...@@ -24,27 +26,34 @@ function HomePage() {
{/* Text Area */} {/* Text Area */}
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">FinWise</h1> <h1 className="clay-title-h1 text-clay-primary">Chào, {user?.fullName || "Người dùng"}!</h1>
<h2 className="clay-title-h3 text-clay-text">Sổ tay Chi tiêu & Báo cáo Tài chính</h2> <h2 className="clay-title-h3 text-clay-text">Sổ tay Chi tiêu & Báo cáo Tài chính</h2>
<p className="clay-caption max-w-xs mx-auto"> <p className="clay-caption max-w-xs mx-auto">
Ứng dụng quản lý tài chính cá nhân thông minh tích hợp Trợ lý AI trên nền tảng Zalo Mini App. Tài khoản: <span className="font-semibold text-clay-primary">{user?.email}</span>
</p> </p>
</div> </div>
{/* Info Card */} {/* Info Card */}
<Card className="w-full max-w-sm"> <Card className="w-full max-w-sm">
<h3 className="font-baloo font-bold text-clay-text text-base mb-1">Thiết kế Claymorphism</h3> <h3 className="font-baloo font-bold text-clay-text text-base mb-1">Xác thực hệ thống</h3>
<p className="text-xs text-clay-text-muted"> <p className="text-xs text-clay-text-muted">
Giao diện đã được cấu hình toàn bộ theo phong cách đất sét 3D mềm mại, bo góc bo tròn chunky và bóng đổ kép dịu mắt. Bạn đã đăng nhập thành công. Các API tiếp theo sẽ tự động được xác thực nhờ cookie an toàn và cơ chế JWT.
</p> </p>
</Card> </Card>
</div> </div>
{/* Navigation CTA */} {/* Navigation CTA */}
<div className="px-4 w-full max-w-sm mx-auto"> <div className="px-4 w-full max-w-sm mx-auto flex flex-col gap-3">
<Button <Button
variant="primary" variant="primary"
fullWidth fullWidth
onClick={() => navigate("/profile")}
>
Trang cá nhân của tôi 👤
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/style-guide")} onClick={() => navigate("/style-guide")}
> >
Khám phá Style Guide 🚀 Khám phá Style Guide 🚀
......
This diff is collapsed.
import { apiClient } from "@/lib/api-client";
import { ApiResponse, User, Session } from "@/types/auth";
export const authService = {
async register(data: any): Promise<ApiResponse> {
const response = await apiClient.post("/auth/register", data);
return response.data;
},
async verifyEmail(token: string): Promise<ApiResponse> {
const response = await apiClient.get(`/auth/verify-email?token=${token}`);
return response.data;
},
async login(data: any): Promise<ApiResponse<{ user: User }>> {
const response = await apiClient.post("/auth/login", data);
return response.data;
},
async getMe(): Promise<ApiResponse<User>> {
const response = await apiClient.get("/auth/me");
return response.data;
},
async logout(): Promise<ApiResponse> {
const response = await apiClient.post("/auth/logout", {});
return response.data;
},
async updateProfile(data: any): Promise<ApiResponse> {
const response = await apiClient.put("/auth/profile", data);
return response.data;
},
async updatePassword(data: any): Promise<ApiResponse> {
const response = await apiClient.put("/auth/password", data);
return response.data;
},
async forgotPassword(data: { email: string }): Promise<ApiResponse> {
const response = await apiClient.post("/auth/forgot-password", data);
return response.data;
},
async resetPassword(data: any): Promise<ApiResponse> {
const response = await apiClient.post("/auth/reset-password", data);
return response.data;
},
async getSessions(): Promise<ApiResponse<Session[]>> {
const response = await apiClient.get("/auth/sessions");
return response.data;
},
async revokeSession(id: string): Promise<ApiResponse> {
const response = await apiClient.delete(`/auth/sessions/${id}`);
return response.data;
},
async revokeOtherSessions(): Promise<ApiResponse> {
const response = await apiClient.delete("/auth/sessions");
return response.data;
},
};
import { create } from "zustand";
import { User } from "@/types/auth";
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isInitialized: boolean;
setAuth: (user: User | null, accessToken: string, refreshToken: string) => void;
clearAuth: () => void;
setUser: (user: User | null) => void;
setInitialized: (initialized: boolean) => void;
}
export const useAuthStore = create<AuthState>((set) => {
// Pre-load tokens from localStorage
const accessToken = localStorage.getItem("accessToken");
const refreshToken = localStorage.getItem("refreshToken");
return {
user: null,
accessToken,
refreshToken,
isAuthenticated: !!accessToken,
isInitialized: false,
setAuth: (user, accessToken, refreshToken) => {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
set({
user,
accessToken,
refreshToken,
isAuthenticated: true,
});
},
clearAuth: () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
});
},
setUser: (user) => {
set({ user });
},
setInitialized: (isInitialized) => {
set({ isInitialized });
},
};
});
export interface Role {
id: string;
name: 'ADMIN' | 'MANAGER' | 'USER';
}
export interface User {
id: string;
email: string;
fullName: string | null;
avatarUrl?: string | null;
phoneNumber?: string | null;
roleId: string;
role: Role;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
export interface Session {
id: string;
deviceName: string;
ipAddress: string;
createdAt: string;
isCurrent: boolean;
}
export interface ApiResponse<T = any> {
success: boolean;
message: string;
data: T;
errors?: any[] | null;
}
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