Commit fa38e40c authored by ThinhNC's avatar ThinhNC

Merge branch 'fix/mobile-responsive-ui-auth-persistence' into 'develop'

fix(fe): optimize mobile viewport scaling and ensure persistent auth session

See merge request !44
parents 1784fd28 2981f315
...@@ -11,6 +11,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; ...@@ -11,6 +11,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { safeStorage } from "@/lib/storage";
import { AuthGuard } from "@/components/shared/AuthGuard"; import { AuthGuard } from "@/components/shared/AuthGuard";
import { DocumentTitle } from "@/components/shared/DocumentTitle"; import { DocumentTitle } from "@/components/shared/DocumentTitle";
import { ThemeControl } from "@/components/shared/ThemeControl"; import { ThemeControl } from "@/components/shared/ThemeControl";
...@@ -78,16 +79,52 @@ const SubscriptionsRedirect: React.FC = () => { ...@@ -78,16 +79,52 @@ const SubscriptionsRedirect: React.FC = () => {
}; };
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => { const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore(); const { setAuth, setUser, clearAuth, setInitialized } = useAuthStore();
useEffect(() => { useEffect(() => {
const initAuth = async () => { const initAuth = async () => {
try { try {
const response = await authService.getMe(); const storedAccessToken = safeStorage.getItem("accessToken");
if (response.success && response.data) { const storedRefreshToken = safeStorage.getItem("refreshToken");
// Access tokens are primarily managed via HTTP-only cookies in development/production
setAuth(response.data, "", ""); // If no tokens exist in storage, user is not logged in
} else { if (!storedAccessToken && !storedRefreshToken) {
clearAuth();
return;
}
// Try getting current user profile with stored access token
try {
const response = await authService.getMe();
if (response.success && response.data) {
setUser(response.data);
return;
}
} catch {
// If getMe failed (e.g. 401 token expired after 30 mins), attempt refresh with storedRefreshToken
if (storedRefreshToken) {
try {
const refreshResponse = await authService.refresh({ refreshToken: storedRefreshToken });
if (refreshResponse.success && refreshResponse.data) {
const { accessToken: newAccess, refreshToken: newRefresh, user } = refreshResponse.data;
setAuth(user || undefined, newAccess, newRefresh || storedRefreshToken);
if (!user) {
const meResponse = await authService.getMe();
if (meResponse.success && meResponse.data) {
setUser(meResponse.data);
return;
}
} else {
return;
}
}
} catch {
// Refresh token is expired (after 7 days) or invalid
clearAuth();
return;
}
}
clearAuth(); clearAuth();
} }
} catch { } catch {
...@@ -98,7 +135,7 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) ...@@ -98,7 +135,7 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
}; };
initAuth(); initAuth();
}, [clearAuth, setAuth, setInitialized]); }, [clearAuth, setAuth, setUser, setInitialized]);
return <>{children}</>; return <>{children}</>;
}; };
......
...@@ -23,7 +23,7 @@ export const Badge: React.FC<BadgeProps> = ({ ...@@ -23,7 +23,7 @@ export const Badge: React.FC<BadgeProps> = ({
return ( return (
<span <span
className={` className={`
inline-flex items-center px-3 py-1 font-nunito font-bold text-xs rounded-full inline-flex items-center px-2.5 py-0.5 font-nunito font-bold text-xs rounded-full
${typeStyles[type]} ${typeStyles[type]}
${className} ${className}
`} `}
......
...@@ -38,7 +38,7 @@ export const Button: React.FC<ButtonProps> = ({ ...@@ -38,7 +38,7 @@ export const Button: React.FC<ButtonProps> = ({
return ( return (
<button <button
className={` className={`
inline-flex items-center justify-center font-baloo font-semibold text-lg px-6 py-2.5 inline-flex items-center justify-center font-baloo font-semibold text-sm sm:text-base px-4 sm:px-5 py-2 sm:py-2.5
transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 select-none transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 select-none
${variantStyles[variant]} ${variantStyles[variant]}
${shapeStyles[shape]} ${shapeStyles[shape]}
......
...@@ -33,7 +33,7 @@ export const Card: React.FC<CardProps> = ({ ...@@ -33,7 +33,7 @@ export const Card: React.FC<CardProps> = ({
onClick={onClick} onClick={onClick}
onKeyDown={isClickable || onKeyDown ? handleKeyDown : undefined} onKeyDown={isClickable || onKeyDown ? handleKeyDown : undefined}
className={` className={`
bg-clay-surface rounded-clay-lg shadow-clay-raised p-6 bg-clay-surface rounded-clay-lg shadow-clay-raised p-4 sm:p-5
transition-all duration-200 ease-in-out border border-clay-highlight/40 transition-all duration-200 ease-in-out border border-clay-highlight/40
${hoverable || isClickable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""} ${hoverable || isClickable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""}
${className} ${className}
......
...@@ -100,11 +100,17 @@ ...@@ -100,11 +100,17 @@
} }
html { html {
font-size: 16px; font-size: 15px;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
text-size-adjust: 100%; text-size-adjust: 100%;
} }
@media (min-width: 400px) {
html {
font-size: 16px;
}
}
html, html,
body, body,
#app { #app {
...@@ -114,7 +120,7 @@ body, ...@@ -114,7 +120,7 @@ body,
body { body {
font-family: "Nunito", sans-serif; font-family: "Nunito", sans-serif;
font-size: 16px; font-size: 1rem;
margin: 0; margin: 0;
color: rgb(var(--color-clay-text)); color: rgb(var(--color-clay-text));
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
...@@ -130,7 +136,11 @@ body { ...@@ -130,7 +136,11 @@ body {
padding: calc( padding: calc(
var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 60px var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 60px
) )
16px 96px; 14px 96px;
@media (min-width: 400px) {
padding-left: 16px;
padding-right: 16px;
}
background-color: rgb(var(--color-clay-bg)); background-color: rgb(var(--color-clay-bg));
min-height: 100vh; min-height: 100vh;
color: rgb(var(--color-clay-text)); color: rgb(var(--color-clay-text));
......
...@@ -73,7 +73,6 @@ apiClient.interceptors.response.use( ...@@ -73,7 +73,6 @@ apiClient.interceptors.response.use(
originalRequest.url?.includes('/auth/forgot-password') || originalRequest.url?.includes('/auth/forgot-password') ||
originalRequest.url?.includes('/auth/reset-password') || originalRequest.url?.includes('/auth/reset-password') ||
originalRequest.url?.includes('/auth/zalo-login') || originalRequest.url?.includes('/auth/zalo-login') ||
originalRequest.url?.includes('/auth/me') ||
originalRequest.url?.includes('/auth/logout'); originalRequest.url?.includes('/auth/logout');
// Check if the error is 401, not an auth endpoint, and the request hasn't been retried yet // Check if the error is 401, not an auth endpoint, and the request hasn't been retried yet
......
...@@ -112,11 +112,11 @@ const LoginPage: React.FC = () => { ...@@ -112,11 +112,11 @@ const LoginPage: React.FC = () => {
<div className="w-full max-w-sm mx-auto flex flex-col gap-5 pt-2"> <div className="w-full max-w-sm mx-auto flex flex-col gap-5 pt-2">
{/* Top: Branding */} {/* Top: Branding */}
<div className="flex flex-col items-center gap-2.5 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<Logo size={72} alt="FinWise" /> <Logo size={64} alt="FinWise" />
<div className="space-y-1"> <div className="space-y-1">
<h1 className="clay-title-h1 text-clay-primary">FinWise</h1> <h1 className="clay-title-h1 text-clay-primary text-2xl sm:text-3xl">FinWise</h1>
<p className="clay-caption">{t("auth.login.subtitle")}</p> <p className="clay-caption text-xs sm:text-sm whitespace-nowrap overflow-hidden text-ellipsis">{t("auth.login.subtitle")}</p>
</div> </div>
</div> </div>
......
...@@ -90,12 +90,12 @@ const RegisterPage: React.FC = () => { ...@@ -90,12 +90,12 @@ const RegisterPage: React.FC = () => {
<Header title={t("auth.register.header")} showBackIcon={true} onBackClick={() => navigate("/login")} /> <Header title={t("auth.register.header")} showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients /> <IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2 pt-2"> <div className="w-full max-w-sm mx-auto flex flex-col gap-6 pt-2">
<div className="flex flex-col items-center gap-2.5 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<Logo size={64} alt="FinWise" /> <Logo size={64} alt="FinWise" />
<div className="space-y-1"> <div className="space-y-1">
<h1 className="clay-title-h1 text-clay-primary">{t("auth.register.title")}</h1> <h1 className="clay-title-h1 text-clay-primary text-2xl sm:text-3xl">{t("auth.register.title")}</h1>
<p className="clay-caption">{t("auth.register.subtitle")}</p> <p className="clay-caption text-xs sm:text-sm whitespace-nowrap overflow-hidden text-ellipsis">{t("auth.register.subtitle")}</p>
</div> </div>
</div> </div>
......
...@@ -104,26 +104,26 @@ function HomePage() { ...@@ -104,26 +104,26 @@ function HomePage() {
<Header title={t("home.header")} showBackIcon={false} /> <Header title={t("home.header")} showBackIcon={false} />
<IconGradients /> <IconGradients />
<div className="flex flex-col items-center justify-start gap-4 pt-2 pb-6"> <div className="flex flex-col items-center justify-start gap-3.5 pt-2 pb-5">
{/* Logo/Avatar Area */} {/* Logo/Avatar Area */}
<div className="relative"> <div className="relative">
<Avatar <Avatar
size="lg" size="md"
src={user?.avatarUrl || ""} src={user?.avatarUrl || ""}
positionX={user?.avatarPositionX} positionX={user?.avatarPositionX}
positionY={user?.avatarPositionY} positionY={user?.avatarPositionY}
className="border-clay-primary shadow-clay-hover" className="border-clay-primary shadow-clay-hover !w-16 !h-16 sm:!w-20 sm:!h-20"
/> />
<div className="absolute -bottom-2 -right-2 bg-clay-primary text-clay-on-primary p-2 rounded-full shadow-clay-raised border border-clay-highlight"> <div className="absolute -bottom-1 -right-1 bg-clay-primary text-clay-on-primary p-1.5 rounded-full shadow-clay-raised border border-clay-highlight">
<AIAssistantIcon size={20} /> <AIAssistantIcon size={18} />
</div> </div>
</div> </div>
{/* Text Area */} {/* Text Area */}
<div className="text-center space-y-1.5 max-w-sm mx-auto"> <div className="text-center space-y-1 max-w-sm mx-auto">
<h1 className="clay-title-h1 text-clay-primary">{t("home.greeting", { name: user?.fullName || t("common.user") })}</h1> <h1 className="clay-title-h1 text-clay-primary text-xl sm:text-2xl">{t("home.greeting", { name: user?.fullName || t("common.user") })}</h1>
<h2 className="clay-title-h3 text-clay-text [text-wrap:balance]">{t("home.subtitle")}</h2> <h2 className="font-baloo font-bold text-clay-text text-[12.5px] min-[390px]:text-sm sm:text-base tracking-tight whitespace-nowrap overflow-hidden text-ellipsis">{t("home.subtitle")}</h2>
<p className="clay-caption max-w-xs mx-auto"> <p className="clay-caption max-w-xs mx-auto text-xs sm:text-sm">
{t("home.account")} <span className="font-semibold text-clay-primary">{user?.email}</span> {t("home.account")} <span className="font-semibold text-clay-primary">{user?.email}</span>
</p> </p>
</div> </div>
......
...@@ -434,41 +434,44 @@ const ProfilePage: React.FC = () => { ...@@ -434,41 +434,44 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col gap-6 mt-4 pb-20"> <div className="flex flex-col gap-6 mt-4 pb-20">
{/* User Card Header */} {/* User Card Header */}
<Card className="flex items-start gap-4 py-4 bg-clay-surface border border-clay-highlight/50"> <Card className="p-4 sm:p-5 bg-clay-surface border border-clay-highlight/50">
<div className="relative h-20 w-20 flex-none self-center sm:self-start"> <div className="flex items-center gap-3.5">
<button <div className="relative h-16 w-16 sm:h-20 sm:w-20 flex-none">
type="button" <button
aria-label={t("profile.editAvatar")} type="button"
onClick={openAvatarEditor} aria-label={t("profile.editAvatar")}
className="flex h-20 w-20 items-center justify-center rounded-full p-0 leading-none transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 focus:ring-clay-primary/25 active:scale-95" onClick={openAvatarEditor}
> className="flex h-16 w-16 sm:h-20 sm:w-20 items-center justify-center rounded-full p-0 leading-none transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 focus:ring-clay-primary/25 active:scale-95"
<Avatar >
size="lg" <Avatar
src={user?.avatarUrl || ""} size="md"
positionX={user?.avatarPositionX} src={user?.avatarUrl || ""}
positionY={user?.avatarPositionY} positionX={user?.avatarPositionX}
className="pointer-events-none border-clay-primary shadow-clay-hover" positionY={user?.avatarPositionY}
/> className="pointer-events-none border-clay-primary shadow-clay-hover !w-16 !h-16 sm:!w-20 sm:!h-20"
</button> />
<span className="pointer-events-none absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full border-2 border-clay-highlight bg-clay-primary text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out"> </button>
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <span className="pointer-events-none absolute bottom-0 right-0 flex h-6 w-6 sm:h-7 sm:w-7 items-center justify-center rounded-full border-2 border-clay-highlight bg-clay-primary text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out">
<path d="M14.5 4 20 9.5 9 20H4v-5Z" /> <svg aria-hidden="true" viewBox="0 0 24 24" className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m12.5 6 5.5 5.5" /> <path d="M14.5 4 20 9.5 9 20H4v-5Z" />
</svg> <path d="m12.5 6 5.5 5.5" />
</span> </svg>
</div> </span>
<div className="flex-1 min-w-0 space-y-1.5">
<div>
<h2 className="clay-title-h2 truncate text-clay-primary">{user?.fullName || t("profile.notSet")}</h2>
<p className="clay-caption truncate text-clay-text-muted">{user?.email || "—"}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex-1 min-w-0 space-y-1">
<Badge type="primary">{user?.role?.name || "USER"}</Badge> <h2 className="clay-title-h2 truncate text-clay-primary text-base sm:text-lg font-bold">{user?.fullName || t("profile.notSet")}</h2>
{user?.isActive && <Badge type="income">{t("common.active")}</Badge>} <p className="clay-caption truncate text-clay-text-muted text-xs">{user?.email || "—"}</p>
<div className="flex items-center gap-1.5 pt-0.5">
<Badge type="primary" className="text-[10.5px] px-2 py-0.5 whitespace-nowrap">{user?.role?.name || "USER"}</Badge>
{user?.isActive && <Badge type="income" className="text-[10.5px] px-2 py-0.5 whitespace-nowrap">{t("common.active")}</Badge>}
</div>
</div> </div>
<div className="flex flex-col gap-1 border-t border-clay-highlight/40 pt-2 text-xs"> </div>
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-semibold text-clay-text-muted shrink-0">{t("profile.userId")}:</span> <div className="mt-3 pt-2.5 border-t border-clay-highlight/40 flex flex-col gap-1 text-xs">
<div className="flex items-center justify-between min-w-0">
<span className="font-semibold text-clay-text-muted shrink-0">{t("profile.userId")}:</span>
<div className="flex items-center gap-1 min-w-0">
<span className="font-mono text-[11px] text-clay-text truncate select-all" title={user?.id}> <span className="font-mono text-[11px] text-clay-text truncate select-all" title={user?.id}>
{user?.id || "—"} {user?.id || "—"}
</span> </span>
...@@ -478,7 +481,7 @@ const ProfilePage: React.FC = () => { ...@@ -478,7 +481,7 @@ const ProfilePage: React.FC = () => {
onClick={handleCopyId} onClick={handleCopyId}
aria-label={t("profile.copyId")} aria-label={t("profile.copyId")}
title={t("profile.copyId")} title={t("profile.copyId")}
className="ml-auto inline-flex shrink-0 items-center justify-center rounded-clay-sm p-1 text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary active:scale-95" className="inline-flex shrink-0 items-center justify-center rounded-clay-sm p-1 text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary active:scale-95"
> >
{isIdCopied ? ( {isIdCopied ? (
<CheckIcon size={14} className="text-clay-income" /> <CheckIcon size={14} className="text-clay-income" />
...@@ -488,18 +491,18 @@ const ProfilePage: React.FC = () => { ...@@ -488,18 +491,18 @@ const ProfilePage: React.FC = () => {
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-1.5"> </div>
<span className="font-semibold text-clay-text-muted shrink-0">{t("profile.createdAt")}:</span> <div className="flex items-center justify-between min-w-0">
<span className="text-[11px] font-medium text-clay-text"> <span className="font-semibold text-clay-text-muted shrink-0">{t("profile.createdAt")}:</span>
{user?.createdAt <span className="text-[11px] font-medium text-clay-text">
? formatDate(user.createdAt, { {user?.createdAt
day: "2-digit", ? formatDate(user.createdAt, {
month: "2-digit", day: "2-digit",
year: "numeric", month: "2-digit",
}) year: "numeric",
: "—"} })
</span> : "—"}
</div> </span>
</div> </div>
</div> </div>
</Card> </Card>
......
...@@ -34,6 +34,11 @@ export const authService = { ...@@ -34,6 +34,11 @@ export const authService = {
return response.data; return response.data;
}, },
async refresh(data?: { refreshToken?: string }): Promise<ApiResponse<{ user?: User; accessToken: string; refreshToken?: string }>> {
const response = await apiClient.post("/auth/refresh", data || {});
return response.data;
},
async logout(): Promise<ApiResponse> { async logout(): Promise<ApiResponse> {
const response = await apiClient.post("/auth/logout", {}); const response = await apiClient.post("/auth/logout", {});
return response.data; return response.data;
......
...@@ -10,7 +10,7 @@ interface AuthState { ...@@ -10,7 +10,7 @@ interface AuthState {
refreshToken: string | null; refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isInitialized: boolean; isInitialized: boolean;
setAuth: (user: User | null, accessToken: string, refreshToken: string) => void; setAuth: (user?: User | null, accessToken?: string, refreshToken?: string) => void;
clearAuth: () => void; clearAuth: () => void;
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
setInitialized: (initialized: boolean) => void; setInitialized: (initialized: boolean) => void;
...@@ -28,14 +28,18 @@ export const useAuthStore = create<AuthState>((set) => { ...@@ -28,14 +28,18 @@ export const useAuthStore = create<AuthState>((set) => {
isAuthenticated: !!accessToken, isAuthenticated: !!accessToken,
isInitialized: false, isInitialized: false,
setAuth: (user, accessToken, refreshToken) => { setAuth: (user, accessToken, refreshToken) => {
safeStorage.setItem("accessToken", accessToken); if (accessToken) {
safeStorage.setItem("refreshToken", refreshToken); safeStorage.setItem("accessToken", accessToken);
set({ }
user, if (refreshToken) {
accessToken, safeStorage.setItem("refreshToken", refreshToken);
refreshToken, }
isAuthenticated: true, set((state) => ({
}); user: user !== undefined ? user : state.user,
accessToken: accessToken || state.accessToken,
refreshToken: refreshToken || state.refreshToken,
isAuthenticated: Boolean(accessToken || state.accessToken || user || state.user),
}));
}, },
clearAuth: () => { clearAuth: () => {
safeStorage.removeItem("accessToken"); safeStorage.removeItem("accessToken");
...@@ -50,7 +54,7 @@ export const useAuthStore = create<AuthState>((set) => { ...@@ -50,7 +54,7 @@ export const useAuthStore = create<AuthState>((set) => {
}); });
}, },
setUser: (user) => { setUser: (user) => {
set({ user }); set((state) => ({ user, isAuthenticated: Boolean(user || state.accessToken) }));
}, },
setInitialized: (isInitialized) => { setInitialized: (isInitialized) => {
set({ isInitialized }); set({ isInitialized });
......
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