Commit 2981f315 authored by ThinhNC's avatar ThinhNC

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

parent dd816551
......@@ -11,6 +11,7 @@ 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 { safeStorage } from "@/lib/storage";
import { AuthGuard } from "@/components/shared/AuthGuard";
import { DocumentTitle } from "@/components/shared/DocumentTitle";
import { ThemeControl } from "@/components/shared/ThemeControl";
......@@ -78,16 +79,52 @@ const SubscriptionsRedirect: React.FC = () => {
};
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
const { setAuth, setUser, clearAuth, setInitialized } = useAuthStore();
useEffect(() => {
const initAuth = async () => {
try {
const storedAccessToken = safeStorage.getItem("accessToken");
const storedRefreshToken = safeStorage.getItem("refreshToken");
// If no tokens exist in storage, user is not logged in
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) {
// Access tokens are primarily managed via HTTP-only cookies in development/production
setAuth(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();
}
} catch {
......@@ -98,7 +135,7 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
};
initAuth();
}, [clearAuth, setAuth, setInitialized]);
}, [clearAuth, setAuth, setUser, setInitialized]);
return <>{children}</>;
};
......
......@@ -23,7 +23,7 @@ export const Badge: React.FC<BadgeProps> = ({
return (
<span
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]}
${className}
`}
......
......@@ -38,7 +38,7 @@ export const Button: React.FC<ButtonProps> = ({
return (
<button
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
${variantStyles[variant]}
${shapeStyles[shape]}
......
......@@ -33,7 +33,7 @@ export const Card: React.FC<CardProps> = ({
onClick={onClick}
onKeyDown={isClickable || onKeyDown ? handleKeyDown : undefined}
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
${hoverable || isClickable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""}
${className}
......
......@@ -100,11 +100,17 @@
}
html {
font-size: 16px;
font-size: 15px;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
@media (min-width: 400px) {
html {
font-size: 16px;
}
}
html,
body,
#app {
......@@ -114,7 +120,7 @@ body,
body {
font-family: "Nunito", sans-serif;
font-size: 16px;
font-size: 1rem;
margin: 0;
color: rgb(var(--color-clay-text));
-webkit-font-smoothing: antialiased;
......@@ -130,7 +136,11 @@ body {
padding: calc(
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));
min-height: 100vh;
color: rgb(var(--color-clay-text));
......
......@@ -73,7 +73,6 @@ apiClient.interceptors.response.use(
originalRequest.url?.includes('/auth/forgot-password') ||
originalRequest.url?.includes('/auth/reset-password') ||
originalRequest.url?.includes('/auth/zalo-login') ||
originalRequest.url?.includes('/auth/me') ||
originalRequest.url?.includes('/auth/logout');
// 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 = () => {
<div className="w-full max-w-sm mx-auto flex flex-col gap-5 pt-2">
{/* Top: Branding */}
<div className="flex flex-col items-center gap-2.5 text-center">
<Logo size={72} alt="FinWise" />
<div className="flex flex-col items-center gap-2 text-center">
<Logo size={64} alt="FinWise" />
<div className="space-y-1">
<h1 className="clay-title-h1 text-clay-primary">FinWise</h1>
<p className="clay-caption">{t("auth.login.subtitle")}</p>
<h1 className="clay-title-h1 text-clay-primary text-2xl sm:text-3xl">FinWise</h1>
<p className="clay-caption text-xs sm:text-sm whitespace-nowrap overflow-hidden text-ellipsis">{t("auth.login.subtitle")}</p>
</div>
</div>
......
......@@ -90,12 +90,12 @@ const RegisterPage: React.FC = () => {
<Header title={t("auth.register.header")} showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2 pt-2">
<div className="flex flex-col items-center gap-2.5 text-center">
<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 text-center">
<Logo size={64} alt="FinWise" />
<div className="space-y-1">
<h1 className="clay-title-h1 text-clay-primary">{t("auth.register.title")}</h1>
<p className="clay-caption">{t("auth.register.subtitle")}</p>
<h1 className="clay-title-h1 text-clay-primary text-2xl sm:text-3xl">{t("auth.register.title")}</h1>
<p className="clay-caption text-xs sm:text-sm whitespace-nowrap overflow-hidden text-ellipsis">{t("auth.register.subtitle")}</p>
</div>
</div>
......
......@@ -104,26 +104,26 @@ function HomePage() {
<Header title={t("home.header")} showBackIcon={false} />
<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 */}
<div className="relative">
<Avatar
size="lg"
size="md"
src={user?.avatarUrl || ""}
positionX={user?.avatarPositionX}
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">
<AIAssistantIcon size={20} />
<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={18} />
</div>
</div>
{/* Text Area */}
<div className="text-center space-y-1.5 max-w-sm mx-auto">
<h1 className="clay-title-h1 text-clay-primary">{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>
<p className="clay-caption max-w-xs mx-auto">
<div className="text-center space-y-1 max-w-sm mx-auto">
<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="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 text-xs sm:text-sm">
{t("home.account")} <span className="font-semibold text-clay-primary">{user?.email}</span>
</p>
</div>
......
......@@ -434,41 +434,44 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col gap-6 mt-4 pb-20">
{/* User Card Header */}
<Card className="flex items-start gap-4 py-4 bg-clay-surface border border-clay-highlight/50">
<div className="relative h-20 w-20 flex-none self-center sm:self-start">
<Card className="p-4 sm:p-5 bg-clay-surface border border-clay-highlight/50">
<div className="flex items-center gap-3.5">
<div className="relative h-16 w-16 sm:h-20 sm:w-20 flex-none">
<button
type="button"
aria-label={t("profile.editAvatar")}
onClick={openAvatarEditor}
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"
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"
size="md"
src={user?.avatarUrl || ""}
positionX={user?.avatarPositionX}
positionY={user?.avatarPositionY}
className="pointer-events-none border-clay-primary shadow-clay-hover"
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">
<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">
<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="M14.5 4 20 9.5 9 20H4v-5Z" />
<path d="m12.5 6 5.5 5.5" />
</svg>
</span>
</div>
<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 className="flex-1 min-w-0 space-y-1">
<h2 className="clay-title-h2 truncate text-clay-primary text-base sm:text-lg font-bold">{user?.fullName || t("profile.notSet")}</h2>
<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 className="flex flex-wrap items-center gap-1.5">
<Badge type="primary">{user?.role?.name || "USER"}</Badge>
{user?.isActive && <Badge type="income">{t("common.active")}</Badge>}
</div>
<div className="flex flex-col gap-1 border-t border-clay-highlight/40 pt-2 text-xs">
<div className="flex items-center gap-1.5 min-w-0">
</div>
<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}>
{user?.id || "—"}
</span>
......@@ -478,7 +481,7 @@ const ProfilePage: React.FC = () => {
onClick={handleCopyId}
aria-label={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 ? (
<CheckIcon size={14} className="text-clay-income" />
......@@ -488,7 +491,8 @@ const ProfilePage: React.FC = () => {
</button>
)}
</div>
<div className="flex items-center gap-1.5">
</div>
<div className="flex items-center justify-between min-w-0">
<span className="font-semibold text-clay-text-muted shrink-0">{t("profile.createdAt")}:</span>
<span className="text-[11px] font-medium text-clay-text">
{user?.createdAt
......@@ -501,7 +505,6 @@ const ProfilePage: React.FC = () => {
</span>
</div>
</div>
</div>
</Card>
......
......@@ -34,6 +34,11 @@ export const authService = {
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> {
const response = await apiClient.post("/auth/logout", {});
return response.data;
......
......@@ -10,7 +10,7 @@ interface AuthState {
refreshToken: string | null;
isAuthenticated: boolean;
isInitialized: boolean;
setAuth: (user: User | null, accessToken: string, refreshToken: string) => void;
setAuth: (user?: User | null, accessToken?: string, refreshToken?: string) => void;
clearAuth: () => void;
setUser: (user: User | null) => void;
setInitialized: (initialized: boolean) => void;
......@@ -28,14 +28,18 @@ export const useAuthStore = create<AuthState>((set) => {
isAuthenticated: !!accessToken,
isInitialized: false,
setAuth: (user, accessToken, refreshToken) => {
if (accessToken) {
safeStorage.setItem("accessToken", accessToken);
}
if (refreshToken) {
safeStorage.setItem("refreshToken", refreshToken);
set({
user,
accessToken,
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: () => {
safeStorage.removeItem("accessToken");
......@@ -50,7 +54,7 @@ export const useAuthStore = create<AuthState>((set) => {
});
},
setUser: (user) => {
set({ user });
set((state) => ({ user, isAuthenticated: Boolean(user || state.accessToken) }));
},
setInitialized: (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