Commit 14d5bba6 authored by ThinhNC's avatar ThinhNC

Merge branch 'develop' into 'master'

Develop

See merge request !70
parents 19036848 0fef9b60
Pipeline #53741 passed with stage
in 34 seconds
import React from "react";
import { useAccountStore } from "@/stores/account-store";
import { Avatar } from "@/components/ui/Avatar";
import { useI18n } from "@/i18n";
export const AccountTransitionOverlay: React.FC = () => {
const isSwitching = useAccountStore((state) => state.isSwitching);
const targetUser = useAccountStore((state) => state.targetSwitchUser);
const { t } = useI18n();
if (!isSwitching) return null;
return (
<div
role="status"
aria-live="polite"
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-clay-bg/85 backdrop-blur-md transition-all duration-300 pointer-events-auto"
>
<div className="flex flex-col items-center gap-4 p-6 rounded-clay-2xl bg-clay-surface/90 border border-clay-highlight shadow-clay-card max-w-xs text-center animate-pulse">
<div className="relative">
<Avatar
size="lg"
src={targetUser?.avatarUrl || undefined}
alt={targetUser?.fullName || t("common.user")}
positionX={targetUser?.avatarPositionX}
positionY={targetUser?.avatarPositionY}
className="border-clay-primary shadow-clay-hover !w-20 !h-20"
/>
<div className="absolute -bottom-1 -right-1 flex h-7 w-7 items-center justify-center rounded-full bg-clay-primary text-clay-on-primary shadow-clay-raised border border-clay-highlight">
<svg
className="animate-spin h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
</div>
<div className="space-y-1">
<p className="font-baloo font-bold text-clay-text text-base">
{targetUser?.fullName || targetUser?.email || t("common.user")}
</p>
<p className="font-nunito text-xs text-clay-primary font-semibold">
{t("auth.switcher.switchingMessage") !== "auth.switcher.switchingMessage"
? t("auth.switcher.switchingMessage")
: "Đang chuyển đổi tài khoản..."}
</p>
</div>
</div>
</div>
);
};
import React, { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Avatar } from "@/components/ui/Avatar";
import { CheckIcon } from "@/components/ui/icons";
import { useAccountStore } from "@/stores/account-store";
import { useI18n } from "@/i18n";
import { useNavigate, useSnackbar } from "zmp-ui";
export const SavedAccountsCard: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { t } = useI18n();
const savedAccounts = useAccountStore((state) => state.savedAccounts);
const isSwitching = useAccountStore((state) => state.isSwitching);
const switchAccount = useAccountStore((state) => state.switchAccount);
const removeSavedAccount = useAccountStore((state) => state.removeSavedAccount);
const [accountToRemove, setAccountToRemove] = useState<string | null>(null);
const handleSelectAccount = async (id: string, isCurrent: boolean, isExpired?: boolean) => {
if (isCurrent || isSwitching) return;
if (isExpired) {
openSnackbar({
type: "warning",
text: t("auth.switcher.expiredPrompt") !== "auth.switcher.expiredPrompt"
? t("auth.switcher.expiredPrompt")
: "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.",
});
navigate("/login");
return;
}
const result = await switchAccount(id);
if (result.success) {
openSnackbar({
type: "success",
text: t("auth.switcher.switchSuccess") !== "auth.switcher.switchSuccess"
? t("auth.switcher.switchSuccess")
: "Đã chuyển đổi tài khoản thành công!",
});
} else if (result.error === "EXPIRED") {
openSnackbar({
type: "warning",
text: t("auth.switcher.expiredPrompt") !== "auth.switcher.expiredPrompt"
? t("auth.switcher.expiredPrompt")
: "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.",
});
navigate("/login");
} else {
openSnackbar({
type: "error",
text: t("auth.switcher.switchFailed") !== "auth.switcher.switchFailed"
? t("auth.switcher.switchFailed")
: "Không thể chuyển tài khoản, vui lòng thử lại.",
});
}
};
const handleConfirmRemove = () => {
if (accountToRemove) {
removeSavedAccount(accountToRemove);
setAccountToRemove(null);
openSnackbar({
type: "info",
text: t("auth.switcher.removedSuccess") !== "auth.switcher.removedSuccess"
? t("auth.switcher.removedSuccess")
: "Đã xóa tài khoản khỏi thiết bị.",
});
}
};
return (
<Card className="p-4 sm:p-5 flex flex-col gap-4 shadow-clay-card border border-clay-highlight/50">
{/* Header */}
<div className="flex items-center justify-between pb-2 border-b border-clay-text/10">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-clay-md bg-clay-primary/15 text-clay-primary">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div>
<h3 className="font-baloo font-bold text-sm sm:text-base text-clay-text">
{t("auth.switcher.cardTitle") !== "auth.switcher.cardTitle"
? t("auth.switcher.cardTitle")
: "Tài khoản trên thiết bị"}
</h3>
<span className="font-nunito text-[11px] text-clay-text-muted">
{savedAccounts.length} / 5{" "}
{t("auth.switcher.accountCount") !== "auth.switcher.accountCount"
? t("auth.switcher.accountCount")
: "tài khoản đã lưu"}
</span>
</div>
</div>
<Button
variant="secondary"
className="text-xs px-2.5 py-1 !h-8 gap-1 font-semibold"
onClick={() => navigate("/login?mode=add-account")}
disabled={isSwitching}
>
<span className="text-sm leading-none">+</span>
<span>
{t("auth.switcher.addAccount") !== "auth.switcher.addAccount"
? t("auth.switcher.addAccount")
: "Thêm"}
</span>
</Button>
</div>
{/* Account List */}
<div className="flex flex-col gap-2.5">
{savedAccounts.map((account) => {
const isCurrent = account.isCurrent;
const isExpired = account.isExpired;
return (
<div
key={account.id}
onClick={() => handleSelectAccount(account.id, isCurrent, isExpired)}
className={`
group relative flex items-center justify-between p-3 rounded-clay-lg transition-all duration-200 select-none
${
isCurrent
? "bg-clay-primary/10 border-2 border-clay-primary/40 shadow-clay-card"
: "bg-clay-surface border border-clay-highlight/60 shadow-clay-raised hover:-translate-y-0.5 hover:shadow-clay-hover cursor-pointer"
}
${isSwitching ? "opacity-60 pointer-events-none" : ""}
`}
>
<div className="flex items-center gap-3 min-w-0 pr-2">
<Avatar
size="sm"
src={account.avatarUrl || undefined}
alt={account.fullName || t("common.user")}
positionX={account.avatarPositionX}
positionY={account.avatarPositionY}
className={isCurrent ? "border-clay-primary" : ""}
/>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-baloo font-bold text-xs sm:text-sm text-clay-text truncate max-w-[130px] sm:max-w-[180px]">
{account.fullName || t("common.user")}
</span>
{isCurrent ? (
<span className="inline-flex items-center gap-0.5 bg-clay-income/25 text-clay-income text-[10px] font-bold px-1.5 py-0.2 rounded-full border border-clay-income/20">
<CheckIcon size={11} />
{t("auth.switcher.activeBadge") !== "auth.switcher.activeBadge"
? t("auth.switcher.activeBadge")
: "Đang dùng"}
</span>
) : isExpired ? (
<span className="inline-flex items-center bg-clay-warning/25 text-clay-warning text-[10px] font-bold px-1.5 py-0.2 rounded-full border border-clay-warning/20">
{t("auth.switcher.expiredBadge") !== "auth.switcher.expiredBadge"
? t("auth.switcher.expiredBadge")
: "Hết hạn"}
</span>
) : (
<span className="inline-flex items-center bg-clay-primary/20 text-clay-primary text-[10px] font-bold px-1.5 py-0.2 rounded-full border border-clay-primary/20">
{t("auth.switcher.switchBadge") !== "auth.switcher.switchBadge"
? t("auth.switcher.switchBadge")
: "Chạm để chuyển"}
</span>
)}
</div>
<span className="font-nunito text-[11px] text-clay-text-muted truncate max-w-[160px] sm:max-w-[220px]">
{account.email || account.phoneNumber || ""}
</span>
</div>
</div>
{/* Action */}
<div className="flex items-center gap-1 shrink-0">
{!isCurrent && (
<button
type="button"
title={
t("auth.switcher.removeTitle") !== "auth.switcher.removeTitle"
? t("auth.switcher.removeTitle")
: "Xóa khỏi máy"
}
onClick={(e) => {
e.stopPropagation();
setAccountToRemove(account.id);
}}
className="p-1.5 rounded-full text-clay-text-muted hover:text-clay-expense hover:bg-clay-expense/10 transition-colors focus:outline-none"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</div>
);
})}
{savedAccounts.length === 0 && (
<p className="font-nunito text-xs text-clay-text-muted text-center py-3">
{t("auth.switcher.noSavedAccounts") !== "auth.switcher.noSavedAccounts"
? t("auth.switcher.noSavedAccounts")
: "Chưa có tài khoản nào được lưu trên thiết bị này."}
</p>
)}
</div>
{/* Inline Confirmation for removing an account */}
{accountToRemove && (
<div className="rounded-clay-lg bg-clay-expense/10 border border-clay-expense/30 p-3 flex flex-col gap-2 animate-fadeIn">
<p className="font-nunito text-xs text-clay-text leading-relaxed">
{t("auth.switcher.removeConfirmPrompt") !== "auth.switcher.removeConfirmPrompt"
? t("auth.switcher.removeConfirmPrompt")
: "Bạn có chắc chắn muốn xóa tài khoản này khỏi thiết bị? Bạn sẽ cần nhập lại mật khẩu nếu muốn đăng nhập lại."}
</p>
<div className="flex items-center justify-end gap-2">
<Button
variant="secondary"
className="text-xs px-2.5 py-1 !h-7"
onClick={() => setAccountToRemove(null)}
>
{t("common.cancel")}
</Button>
<Button
variant="primary"
className="text-xs px-2.5 py-1 !h-7 bg-clay-expense text-white hover:bg-clay-expense-dark"
onClick={handleConfirmRemove}
>
{t("common.delete")}
</Button>
</div>
</div>
)}
</Card>
);
};
......@@ -10,6 +10,7 @@ import {
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
import { useAuthStore, GUEST_STORAGE_KEY } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { authService } from "@/services/auth.service";
import { safeStorage } from "@/lib/storage";
import { AuthGuard } from "@/components/shared/AuthGuard";
......@@ -20,6 +21,7 @@ import { I18nProvider } from "@/i18n";
import { LanguageSwitcher } from "@/components/language-switcher/LanguageSwitcher";
import { NotificationShortcut } from "@/components/shared/NotificationShortcut";
import { NotificationRealtimeListener } from "@/components/shared/NotificationRealtimeListener";
import { AccountTransitionOverlay } from "@/components/auth/AccountTransitionOverlay";
import { ErrorBoundary } from "@/components/ErrorBoundary";
......@@ -110,6 +112,9 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
const response = await authService.getMe();
if (response.success && response.data) {
setUser(response.data);
if (storedRefreshToken) {
useAccountStore.getState().addOrUpdateAccount(response.data, storedRefreshToken);
}
return;
}
} catch {
......@@ -119,15 +124,18 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
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);
const effectiveRefresh = newRefresh || storedRefreshToken;
setAuth(user || undefined, newAccess, effectiveRefresh);
if (!user) {
const meResponse = await authService.getMe();
if (meResponse.success && meResponse.data) {
setUser(meResponse.data);
useAccountStore.getState().addOrUpdateAccount(meResponse.data, effectiveRefresh);
return;
}
} else {
useAccountStore.getState().addOrUpdateAccount(user, effectiveRefresh);
return;
}
}
......@@ -174,6 +182,7 @@ export const Layout = () => {
</div>
<AuthInitializer>
<NotificationRealtimeListener />
<AccountTransitionOverlay />
<React.Suspense fallback={<PageLoadingFallback />}>
<AnimationRoutes>
{/* Public Auth Routes */}
......
......@@ -202,7 +202,31 @@
"contextExpand": "Details",
"guestExplore": "Try Demo",
"guestExploreSubtext": "Decline login and access the app to experience features with sample data",
"orEmail": "Or sign in with Email"
"orEmail": "Or sign in with Email",
"chooseAccount": "Choose Account",
"chooseAccountTitle": "Choose an account to continue",
"chooseAccountSubtitle": "Tap to sign in instantly without password",
"useAnotherAccount": "Use another account",
"backToSavedAccounts": "Back to saved accounts"
},
"switcher": {
"cardTitle": "Accounts on this device",
"accountCount": "saved accounts",
"addAccount": "Add",
"activeBadge": "Active",
"expiredBadge": "Expired",
"switchBadge": "Tap to switch",
"fastLogin": "Sign in",
"removeTitle": "Remove from device",
"removeConfirmPrompt": "Are you sure you want to remove this account from this device? You will need to re-enter password to sign in again.",
"removedSuccess": "Account removed from this device.",
"noSavedAccounts": "No accounts currently saved on this device.",
"switchSuccess": "Switched account successfully!",
"switchFailed": "Could not switch account, please try again.",
"switchingMessage": "Switching account...",
"expiredPrompt": "Session has expired. Please sign in again.",
"alreadyActive": "You are currently signed in with this account.",
"logoutAndSwitched": "Signed out of this account and switched to next saved account."
},
"guest": {
"badge": "Demo Mode",
......
......@@ -202,7 +202,31 @@
"contextExpand": "Chi tiết",
"guestExplore": "Dùng thử",
"guestExploreSubtext": "Từ chối đăng nhập và truy cập ứng dụng để trải nghiệm với dữ liệu mẫu",
"orEmail": "Hoặc đăng nhập bằng tài khoản Email"
"orEmail": "Hoặc đăng nhập bằng tài khoản Email",
"chooseAccount": "Chọn tài khoản",
"chooseAccountTitle": "Chọn tài khoản để tiếp tục",
"chooseAccountSubtitle": "Chạm để đăng nhập ngay mà không cần mật khẩu",
"useAnotherAccount": "Đăng nhập bằng tài khoản khác",
"backToSavedAccounts": "Chọn từ tài khoản đã lưu"
},
"switcher": {
"cardTitle": "Tài khoản trên thiết bị này",
"accountCount": "tài khoản đã lưu",
"addAccount": "Thêm",
"activeBadge": "Đang dùng",
"expiredBadge": "Hết hạn",
"switchBadge": "Chạm để chuyển",
"fastLogin": "Đăng nhập",
"removeTitle": "Xóa khỏi máy",
"removeConfirmPrompt": "Bạn có chắc chắn muốn xóa tài khoản này khỏi thiết bị? Bạn sẽ cần nhập lại mật khẩu nếu muốn đăng nhập lại.",
"removedSuccess": "Đã xóa tài khoản khỏi thiết bị.",
"noSavedAccounts": "Chưa có tài khoản nào được lưu trên thiết bị này.",
"switchSuccess": "Đã chuyển đổi tài khoản thành công!",
"switchFailed": "Không thể chuyển tài khoản, vui lòng thử lại.",
"switchingMessage": "Đang chuyển đổi tài khoản...",
"expiredPrompt": "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.",
"alreadyActive": "Bạn hiện đang đăng nhập bằng tài khoản này.",
"logoutAndSwitched": "Đã đăng xuất tài khoản này và chuyển sang tài khoản tiếp theo."
},
"guest": {
"badge": "Chế độ Dùng thử",
......
import { safeStorage } from "./storage";
const DEVICE_KEY_STORAGE = "finwise.deviceKey";
/**
* Helper to convert Uint8Array to hex string
*/
function toHex(buffer: Uint8Array): string {
return Array.from(buffer)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Helper to convert hex string to Uint8Array
*/
function fromHex(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
let cachedKey: CryptoKey | null = null;
/**
* Gets or creates a local device-scoped CryptoKey using Web Crypto API.
*/
async function getDeviceKey(): Promise<CryptoKey | null> {
if (cachedKey) return cachedKey;
if (typeof window === "undefined" || !window.crypto?.subtle) {
return null;
}
try {
let rawKeyHex = safeStorage.getItem(DEVICE_KEY_STORAGE);
let rawKeyBytes: Uint8Array;
if (!rawKeyHex) {
rawKeyBytes = new Uint8Array(32); // 256 bits
window.crypto.getRandomValues(rawKeyBytes);
safeStorage.setItem(DEVICE_KEY_STORAGE, toHex(rawKeyBytes));
} else {
rawKeyBytes = fromHex(rawKeyHex);
}
cachedKey = await window.crypto.subtle.importKey(
"raw",
rawKeyBytes as unknown as BufferSource,
{ name: "AES-GCM" },
false,
["encrypt", "decrypt"]
);
return cachedKey;
} catch (error) {
console.warn("[AccountCrypto] Failed to initialize device key:", error);
return null;
}
}
/**
* Encrypts a raw token string using AES-GCM 256-bit.
*/
export async function encryptToken(rawToken: string): Promise<{ encrypted: string; iv: string }> {
const key = await getDeviceKey();
// Fallback if Web Crypto is not supported in the current environment
if (!key || typeof window === "undefined" || !window.crypto?.subtle) {
const fakeIv = toHex(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]));
const encoded = btoa(encodeURIComponent(rawToken));
return { encrypted: encoded, iv: fakeIv };
}
const iv = new Uint8Array(12);
window.crypto.getRandomValues(iv);
const encodedData = new TextEncoder().encode(rawToken);
const ciphertextBuffer = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
encodedData
);
return {
encrypted: toHex(new Uint8Array(ciphertextBuffer)),
iv: toHex(iv),
};
}
/**
* Decrypts an encrypted token string using AES-GCM 256-bit.
*/
export async function decryptToken(encrypted: string, iv: string): Promise<string> {
const key = await getDeviceKey();
// Fallback if Web Crypto is not supported
if (!key || typeof window === "undefined" || !window.crypto?.subtle) {
try {
return decodeURIComponent(atob(encrypted));
} catch {
return encrypted;
}
}
try {
const ivBytes = fromHex(iv);
const ciphertextBytes = fromHex(encrypted);
const decryptedBuffer = await window.crypto.subtle.decrypt(
{ name: "AES-GCM", iv: ivBytes as unknown as BufferSource },
key,
ciphertextBytes as unknown as BufferSource
);
return new TextDecoder().decode(decryptedBuffer);
} catch (error) {
console.warn("[AccountCrypto] Decryption failed:", error);
throw new Error("DECRYPTION_FAILED");
}
}
import axios from "axios";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { safeStorage } from "@/lib/storage";
export const API_BASE_URL =
......@@ -38,7 +39,8 @@ apiClient.interceptors.request.use(
config.url?.includes("/auth/register") ||
config.url?.includes("/auth/zalo-login") ||
config.url?.includes("/auth/forgot-password") ||
config.url?.includes("/auth/reset-password");
config.url?.includes("/auth/reset-password") ||
config.url?.includes("/auth/switch");
if (isGuest && !isAuthEndpoint) {
config.adapter = async () => handleGuestRequest(config);
......@@ -90,6 +92,7 @@ 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/switch') ||
originalRequest.url?.includes('/auth/logout');
// Check if the error is 401, not an auth endpoint, and the request hasn't been retried yet
......@@ -134,6 +137,9 @@ apiClient.interceptors.response.use(
tokenStr,
newData.refreshToken || ""
);
if (newData.refreshToken) {
useAccountStore.getState().updateActiveRefreshToken(newData.refreshToken);
}
originalRequest.headers.Authorization = `Bearer ${tokenStr}`;
}
......
......@@ -6,19 +6,23 @@ import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Avatar } from "@/components/ui/Avatar";
import {
IconGradients,
EyeIcon,
EyeOffIcon,
ShieldCheckIcon,
ChevronDownIcon,
CheckIcon,
} from "@/components/ui/icons";
import { Logo } from "@/components/logo";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { authService } from "@/services/auth.service";
import { getErrorMessage } from "@/lib/error-message";
import { safeStorage } from "@/lib/storage";
import { TranslationFunction, useI18n } from "@/i18n";
import { SavedAccount } from "@/types/account";
const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail";
const LOGIN_CONTEXT_STORAGE_KEY = "finwise.loginContextOpen";
......@@ -51,6 +55,15 @@ const LoginPage: React.FC = () => {
const setGuestMode = useAuthStore((state) => state.setGuestMode);
const { t } = useI18n();
const savedAccounts = useAccountStore((state) => state.savedAccounts);
const switchAccount = useAccountStore((state) => state.switchAccount);
const isSwitching = useAccountStore((state) => state.isSwitching);
const removeSavedAccount = useAccountStore((state) => state.removeSavedAccount);
const isAddAccountMode =
typeof window !== "undefined" && window.location.search.includes("mode=add-account");
const [showOtherAccountForm, setShowOtherAccountForm] = useState(isAddAccountMode);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [rememberedEmail] = useState(getRememberedEmail);
......@@ -69,7 +82,7 @@ const LoginPage: React.FC = () => {
});
};
const isBusy = isSubmitting;
const isBusy = isSubmitting || isSwitching;
const handleGuestExplore = () => {
setGuestMode();
......@@ -89,6 +102,7 @@ const LoginPage: React.FC = () => {
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
......@@ -99,6 +113,50 @@ const LoginPage: React.FC = () => {
},
});
const handleSelectSavedAccount = async (account: SavedAccount) => {
if (isBusy) return;
if (account.isExpired) {
setShowOtherAccountForm(true);
setValue("email", account.email || account.phoneNumber || "");
openSnackbar({
type: "warning",
text:
t("auth.switcher.expiredPrompt") !== "auth.switcher.expiredPrompt"
? t("auth.switcher.expiredPrompt")
: "Phiên đăng nhập đã hết hạn. Vui lòng nhập mật khẩu.",
});
return;
}
const result = await switchAccount(account.id);
if (result.success) {
openSnackbar({
type: "success",
text: t("auth.login.success"),
});
navigate("/", { replace: true });
} else if (result.error === "EXPIRED") {
setShowOtherAccountForm(true);
setValue("email", account.email || account.phoneNumber || "");
openSnackbar({
type: "warning",
text:
t("auth.switcher.expiredPrompt") !== "auth.switcher.expiredPrompt"
? t("auth.switcher.expiredPrompt")
: "Phiên đăng nhập đã hết hạn. Vui lòng nhập mật khẩu.",
});
} else {
openSnackbar({
type: "error",
text:
t("auth.switcher.switchFailed") !== "auth.switcher.switchFailed"
? t("auth.switcher.switchFailed")
: "Không thể đăng nhập tài khoản này, vui lòng thử lại.",
});
}
};
const onSubmit = async (values: LoginFormValues) => {
setIsSubmitting(true);
try {
......@@ -108,12 +166,32 @@ const LoginPage: React.FC = () => {
});
if (response.success && response.data) {
// Guard check: is user already active?
const currentUser = useAuthStore.getState().user;
if (currentUser && currentUser.id === response.data.user.id) {
openSnackbar({
type: "info",
text:
t("auth.switcher.alreadyActive") !== "auth.switcher.alreadyActive"
? t("auth.switcher.alreadyActive")
: "Bạn hiện đang đăng nhập bằng tài khoản này.",
});
navigate("/", { replace: true });
return;
}
// Save account into useAccountStore
await useAccountStore
.getState()
.addOrUpdateAccount(response.data.user, response.data.refreshToken || "");
updateRememberedEmail(values.email, Boolean(values.rememberMe));
setAuth(
response.data.user,
response.data.accessToken || "",
response.data.refreshToken || ""
);
openSnackbar({
type: "success",
text: t("auth.login.success"),
......@@ -135,189 +213,337 @@ const LoginPage: React.FC = () => {
}
};
// Determine whether to display the Account Chooser
const hasSavedAccounts = savedAccounts.length > 0;
const showAccountChooser = hasSavedAccounts && !showOtherAccountForm;
return (
<Page className="page flex flex-col overflow-y-auto">
<Header title={t("auth.login.header")} showBackIcon={false} />
<Header
title={
showAccountChooser
? (t("auth.login.chooseAccount") !== "auth.login.chooseAccount"
? t("auth.login.chooseAccount")
: "Chọn tài khoản")
: t("auth.login.header")
}
showBackIcon={false}
/>
<IconGradients />
<div className="flex flex-1 items-center justify-center px-4 py-6">
<div className="w-full max-w-sm flex flex-col gap-4">
{/* Top: Branding */}
<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 text-2xl sm:text-3xl">FinWise</h1>
<p className="clay-caption text-xs sm:text-sm font-medium text-clay-primary/80">
{t("auth.login.appDescription")}
</p>
<div className="w-full max-w-sm flex flex-col gap-4">
{/* Top: Branding */}
<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 text-2xl sm:text-3xl">FinWise</h1>
<p className="clay-caption text-xs sm:text-sm font-medium text-clay-primary/80">
{t("auth.login.appDescription")}
</p>
</div>
</div>
</div>
{/* Context Notice Card: Explains clearly why login is required per Zalo guidelines with toggle */}
<div className="rounded-2xl bg-clay-primary/10 border border-clay-primary/25 p-3 shadow-clay-card text-left transition-all">
<div
className="flex items-center justify-between cursor-pointer select-none"
onClick={toggleContext}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleContext();
}
}}
>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-clay-primary/20 text-clay-primary">
<ShieldCheckIcon size={14} />
</div>
<h2 className="font-baloo text-xs font-bold text-clay-primary uppercase tracking-wider">
{t("auth.login.contextTitle")}
</h2>
</div>
<button
type="button"
className="flex items-center gap-1 text-[11px] font-medium text-clay-primary hover:text-clay-primary-dark transition-colors px-1.5 py-0.5 rounded-md hover:bg-clay-primary/10"
onClick={(e) => {
e.stopPropagation();
toggleContext();
{/* Context Notice Card */}
<div className="rounded-2xl bg-clay-primary/10 border border-clay-primary/25 p-3 shadow-clay-card text-left transition-all">
<div
className="flex items-center justify-between cursor-pointer select-none"
onClick={toggleContext}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleContext();
}
}}
aria-expanded={isContextOpen}
aria-label={isContextOpen ? t("auth.login.contextCollapse") : t("auth.login.contextExpand")}
>
<span>{isContextOpen ? t("auth.login.contextCollapse") : t("auth.login.contextExpand")}</span>
<ChevronDownIcon
size={14}
className={`transition-transform duration-200 ${isContextOpen ? "rotate-180" : ""}`}
/>
</button>
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-clay-primary/20 text-clay-primary">
<ShieldCheckIcon size={14} />
</div>
<h2 className="font-baloo text-xs font-bold text-clay-primary uppercase tracking-wider">
{t("auth.login.contextTitle")}
</h2>
</div>
<button
type="button"
className="flex items-center gap-1 text-[11px] font-medium text-clay-primary hover:text-clay-primary-dark transition-colors px-1.5 py-0.5 rounded-md hover:bg-clay-primary/10"
onClick={(e) => {
e.stopPropagation();
toggleContext();
}}
aria-expanded={isContextOpen}
aria-label={
isContextOpen ? t("auth.login.contextCollapse") : t("auth.login.contextExpand")
}
>
<span>
{isContextOpen ? t("auth.login.contextCollapse") : t("auth.login.contextExpand")}
</span>
<ChevronDownIcon
size={14}
className={`transition-transform duration-200 ${isContextOpen ? "rotate-180" : ""}`}
/>
</button>
</div>
{isContextOpen && (
<div className="mt-2.5 pt-2.5 border-t border-clay-primary/15">
<p className="font-nunito text-[11px] text-clay-text-muted leading-relaxed">
{t("auth.login.contextNotice")}
</p>
</div>
)}
</div>
{isContextOpen && (
<div className="mt-2.5 pt-2.5 border-t border-clay-primary/15">
<p className="font-nunito text-[11px] text-clay-text-muted leading-relaxed">
{t("auth.login.contextNotice")}
</p>
</div>
)}
</div>
{/* View 1: Account Chooser (Google-Style) */}
{showAccountChooser ? (
<Card className="p-5 flex flex-col gap-4 shadow-clay-raised animate-fadeIn">
<div className="space-y-1 text-center">
<h2 className="font-baloo font-bold text-base text-clay-text">
{t("auth.login.chooseAccountTitle") !== "auth.login.chooseAccountTitle"
? t("auth.login.chooseAccountTitle")
: "Chọn tài khoản để tiếp tục"}
</h2>
<p className="font-nunito text-xs text-clay-text-muted">
{t("auth.login.chooseAccountSubtitle") !== "auth.login.chooseAccountSubtitle"
? t("auth.login.chooseAccountSubtitle")
: "Chạm để đăng nhập ngay mà không cần mật khẩu"}
</p>
</div>
{/* Card: Login Form */}
<Card className="p-5 flex flex-col gap-4 shadow-clay-raised">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
id="login-email-input"
label={t("auth.login.account") || t("auth.email")}
placeholder={t("auth.login.accountPlaceholder") || "user@gmail.com"}
type="text"
autoComplete="username email"
error={errors.email?.message}
{...register("email")}
disabled={isBusy}
/>
<Input
id="login-password-input"
label={t("auth.password")}
placeholder="••••••••"
type={isPasswordVisible ? "text" : "password"}
autoComplete="current-password"
error={errors.password?.message}
{...register("password")}
disabled={isSubmitting}
endAdornment={
<button
type="button"
aria-label={t(
isPasswordVisible
? "auth.login.hidePassword"
: "auth.login.showPassword"
)}
aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((visible) => !visible)}
disabled={isSubmitting}
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="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 select-none">
<input
type="checkbox"
{...register("rememberMe")}
{/* List of saved accounts */}
<div className="flex flex-col gap-2">
{savedAccounts.map((account) => {
const isExpired = account.isExpired;
return (
<div
key={account.id}
onClick={() => handleSelectSavedAccount(account)}
className={`
group flex items-center justify-between p-3 rounded-clay-lg transition-all duration-200 select-none
bg-clay-surface border border-clay-highlight/60 shadow-clay-raised
hover:-translate-y-0.5 hover:shadow-clay-hover cursor-pointer active:scale-[0.99]
${isBusy ? "opacity-60 pointer-events-none" : ""}
`}
>
<div className="flex items-center gap-3 min-w-0 pr-2">
<Avatar
size="sm"
src={account.avatarUrl || undefined}
alt={account.fullName || t("common.user")}
positionX={account.avatarPositionX}
positionY={account.avatarPositionY}
/>
<div className="flex flex-col min-w-0">
<span className="font-baloo font-bold text-xs sm:text-sm text-clay-text truncate max-w-[150px] sm:max-w-[200px]">
{account.fullName || t("common.user")}
</span>
<span className="font-nunito text-[11px] text-clay-text-muted truncate max-w-[150px] sm:max-w-[200px]">
{account.email || account.phoneNumber || ""}
</span>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{isExpired ? (
<span className="inline-flex items-center bg-clay-warning/25 text-clay-warning text-[10px] font-bold px-2 py-0.5 rounded-full border border-clay-warning/20">
{t("auth.switcher.expiredBadge") !== "auth.switcher.expiredBadge"
? t("auth.switcher.expiredBadge")
: "Hết hạn"}
</span>
) : (
<span className="inline-flex items-center bg-clay-primary/20 text-clay-primary text-[10px] font-bold px-2 py-0.5 rounded-full border border-clay-primary/20">
{t("auth.switcher.fastLogin") !== "auth.switcher.fastLogin"
? t("auth.switcher.fastLogin")
: "Đăng nhập"}
</span>
)}
<button
type="button"
title={
t("auth.switcher.removeTitle") !== "auth.switcher.removeTitle"
? t("auth.switcher.removeTitle")
: "Xóa khỏi máy"
}
onClick={(e) => {
e.stopPropagation();
removeSavedAccount(account.id);
}}
className="p-1 rounded-full text-clay-text-muted hover:text-clay-expense hover:bg-clay-expense/10 transition-colors focus:outline-none"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
);
})}
</div>
{/* Action: Use Another Account */}
<Button
variant="secondary"
fullWidth
onClick={() => setShowOtherAccountForm(true)}
className="font-semibold text-xs py-2.5 shadow-clay-raised gap-1.5"
>
<span>+</span>
<span>
{t("auth.login.useAnotherAccount") !== "auth.login.useAnotherAccount"
? t("auth.login.useAnotherAccount")
: "Đăng nhập bằng tài khoản khác"}
</span>
</Button>
</Card>
) : (
/* View 2: Standard Login Form */
<Card className="p-5 flex flex-col gap-4 shadow-clay-raised animate-fadeIn">
{hasSavedAccounts && (
<div className="flex items-center justify-between pb-1">
<button
type="button"
onClick={() => setShowOtherAccountForm(false)}
className="flex items-center gap-1 font-nunito text-xs text-clay-primary font-bold hover:underline focus:outline-none"
>
<span>←</span>
<span>
{t("auth.login.backToSavedAccounts") !== "auth.login.backToSavedAccounts"
? t("auth.login.backToSavedAccounts")
: "Chọn tài khoản đã lưu"}
</span>
</button>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
id="login-email-input"
label={t("auth.login.account") || t("auth.email")}
placeholder={t("auth.login.accountPlaceholder") || "user@gmail.com"}
type="text"
autoComplete="username email"
error={errors.email?.message}
{...register("email")}
disabled={isBusy}
/>
<Input
id="login-password-input"
label={t("auth.password")}
placeholder="••••••••"
type={isPasswordVisible ? "text" : "password"}
autoComplete="current-password"
error={errors.password?.message}
{...register("password")}
disabled={isSubmitting}
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"
endAdornment={
<button
type="button"
aria-label={t(
isPasswordVisible
? "auth.login.hidePassword"
: "auth.login.showPassword"
)}
aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((visible) => !visible)}
disabled={isSubmitting}
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>
}
/>
<span>{t("auth.login.rememberEmail")}</span>
</label>
<span
onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer select-none"
>
{t("auth.login.forgotPassword")}
</span>
</div>
<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 select-none">
<input
type="checkbox"
{...register("rememberMe")}
disabled={isSubmitting}
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>{t("auth.login.rememberEmail")}</span>
</label>
<span
onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer select-none"
>
{t("auth.login.forgotPassword")}
</span>
</div>
<Button
id="btn-login-submit"
variant="primary"
type="submit"
fullWidth
disabled={isBusy}
className="mt-1"
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<div className="h-5 w-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>{t("auth.login.submitting")}</span>
</div>
) : (
t("auth.login.submit")
)}
</Button>
</form>
</Card>
)}
{/* Link to Register */}
<div className="text-center text-sm font-nunito text-clay-text-muted">
{t("auth.login.noAccount")}{" "}
<span
onClick={() => navigate("/register")}
className="text-clay-primary font-bold hover:underline cursor-pointer select-none"
>
{t("auth.login.registerNow")}
</span>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-0.5">
<div className="h-[1px] flex-1 bg-clay-border/40" />
<span className="font-nunito text-xs font-semibold text-clay-text-muted uppercase tracking-wider">
{t("auth.login.or")}
</span>
<div className="h-[1px] flex-1 bg-clay-border/40" />
</div>
{/* Action: Guest Exploration Mode */}
<div className="flex flex-col gap-1.5">
<Button
id="btn-login-submit"
variant="primary"
type="submit"
id="btn-guest-explore"
variant="secondary"
fullWidth
disabled={isBusy}
className="mt-1"
onClick={handleGuestExplore}
className="border border-clay-primary/30 font-semibold shadow-clay-hover active:scale-[0.98] py-2.5"
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<div className="h-5 w-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>{t("auth.login.submitting")}</span>
</div>
) : (
t("auth.login.submit")
)}
{t("auth.login.guestExplore")}
</Button>
</form>
</Card>
{/* Link to Register */}
<div className="text-center text-sm font-nunito text-clay-text-muted">
{t("auth.login.noAccount")}{" "}
<span
onClick={() => navigate("/register")}
className="text-clay-primary font-bold hover:underline cursor-pointer select-none"
>
{t("auth.login.registerNow")}
</span>
</div>
{/* Divider */}
<div className="flex items-center gap-3 my-0.5">
<div className="h-[1px] flex-1 bg-clay-border/40" />
<span className="font-nunito text-xs font-semibold text-clay-text-muted uppercase tracking-wider">
{t("auth.login.or")}
</span>
<div className="h-[1px] flex-1 bg-clay-border/40" />
</div>
{/* Action: Guest Exploration Mode (Placed at the bottom) */}
<div className="flex flex-col gap-1.5">
<Button
id="btn-guest-explore"
variant="secondary"
fullWidth
onClick={handleGuestExplore}
className="border border-clay-primary/30 font-semibold shadow-clay-hover active:scale-[0.98] py-2.5"
>
{t("auth.login.guestExplore")}
</Button>
<p className="text-center font-nunito text-[11px] text-clay-text-muted">
{t("auth.login.guestExploreSubtext")}
</p>
<p className="text-center font-nunito text-[11px] text-clay-text-muted">
{t("auth.login.guestExploreSubtext")}
</p>
</div>
</div>
</div>
</div>
</Page>
);
};
......
......@@ -3,6 +3,7 @@ import { Page, Header, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import {
AIAssistantIcon,
AlertTriangleIcon,
......@@ -28,6 +29,7 @@ import { GuestModeBanner } from "@/components/shared/GuestModeBanner";
function HomePage() {
const navigate = useNavigate();
const { user } = useAuthStore();
const savedAccounts = useAccountStore((state) => state.savedAccounts);
const { t } = useI18n();
const showStyleGuide = user?.role.name !== SYSTEM_ROLES.USER;
......@@ -130,8 +132,29 @@ function HomePage() {
<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 className="clay-caption max-w-xs mx-auto text-xs sm:text-sm flex items-center justify-center gap-1 flex-wrap">
<span>{t("home.account")}</span>
<span className="font-semibold text-clay-primary">{user?.email}</span>
{savedAccounts.length > 1 && (
<button
type="button"
onClick={() => navigate("/profile")}
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-clay-primary/15 text-clay-primary text-[10px] font-bold hover:bg-clay-primary/25 transition-colors cursor-pointer select-none"
title={
t("auth.switcher.cardTitle") !== "auth.switcher.cardTitle"
? t("auth.switcher.cardTitle")
: "Tài khoản trên thiết bị"
}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>{savedAccounts.length}</span>
</button>
)}
</p>
</div>
</div>
......
......@@ -12,6 +12,8 @@ import { Avatar } from "@/components/ui/Avatar";
import { Tabs } from "@/components/ui/Tabs";
import { Badge } from "@/components/ui/Badge";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { SavedAccountsCard } from "@/components/auth/SavedAccountsCard";
import { authService } from "@/services/auth.service";
import { uploadService } from "@/services/upload.service";
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon, IconGradients, SendIcon } from "@/components/ui/icons";
......@@ -318,16 +320,26 @@ const ProfilePage: React.FC = () => {
const handleLogout = async () => {
try {
await authService.logout();
const switchedToNext = await useAccountStore.getState().logoutCurrentAccount();
if (switchedToNext) {
openSnackbar({
type: "success",
text:
t("auth.switcher.logoutAndSwitched") !== "auth.switcher.logoutAndSwitched"
? t("auth.switcher.logoutAndSwitched")
: "Đã đăng xuất tài khoản này và chuyển sang tài khoản tiếp theo.",
});
navigate("/", { replace: true });
return;
}
openSnackbar({
type: "success",
text: t("profile.logoutSuccess"),
});
} catch (err) {
// Still clear local auth state if logout call fails (e.g. server down)
} catch {
clearAuth();
} finally {
queryClient.clear();
clearAuth();
navigate("/login", { replace: true });
}
};
......@@ -421,7 +433,7 @@ const ProfilePage: React.FC = () => {
<Header title={t("profile.header")} showBackIcon={true} onBackClick={() => navigate("/")} />
<IconGradients />
<div className="flex flex-col gap-6 mt-4 pb-20">
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-6 pb-20">
{/* User Card Header */}
<Card className="p-4 sm:p-5 bg-clay-surface border border-clay-highlight/50">
<div className="flex items-center gap-3.5">
......@@ -496,6 +508,8 @@ const ProfilePage: React.FC = () => {
</div>
</Card>
{/* Multi-Account Switcher & Saved Accounts */}
<SavedAccountsCard />
{/* Navigation Tabs */}
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
......@@ -692,12 +706,12 @@ const ProfilePage: React.FC = () => {
{/* Global Logout Button */}
<div className="px-4 pb-6">
<div className="pb-6">
<Button variant="secondary" onClick={handleLogout} fullWidth className="text-clay-expense">
{t("profile.logout")}
</Button>
</div>
</div>
</main>
<AvatarEditorModal
isOpen={isAvatarEditorOpen}
......
......@@ -39,6 +39,11 @@ export const authService = {
return response.data;
},
async switchAccount(data: { refreshToken: string }): Promise<ApiResponse<{ user: User; accessToken: string; refreshToken: string }>> {
const response = await apiClient.post("/auth/switch", data);
return response.data;
},
async logout(): Promise<ApiResponse> {
const response = await apiClient.post("/auth/logout", {});
return response.data;
......
import { create } from "zustand";
import { User } from "@/types/auth";
import { SavedAccount, AccountSwitcherState } from "@/types/account";
import { safeStorage } from "@/lib/storage";
import { encryptToken, decryptToken } from "@/lib/account-crypto";
import { authService } from "@/services/auth.service";
import { useAuthStore } from "./auth-store";
import { queryClient } from "@/lib/query-client";
export const SAVED_ACCOUNTS_STORAGE_KEY = "finwise.savedAccounts";
const MAX_SAVED_ACCOUNTS = 5;
const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function loadSavedAccounts(): SavedAccount[] {
try {
const raw = safeStorage.getItem(SAVED_ACCOUNTS_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed;
}
return [];
} catch (error) {
console.warn("[AccountStore] Failed to load saved accounts:", error);
return [];
}
}
function persistSavedAccounts(accounts: SavedAccount[]): void {
try {
safeStorage.setItem(SAVED_ACCOUNTS_STORAGE_KEY, JSON.stringify(accounts));
} catch (error) {
console.warn("[AccountStore] Failed to persist saved accounts:", error);
}
}
export const useAccountStore = create<AccountSwitcherState>((set, get) => ({
savedAccounts: loadSavedAccounts(),
isSwitching: false,
targetSwitchUser: null,
addOrUpdateAccount: async (user: User, refreshToken: string) => {
if (!user || !user.id || !refreshToken) return;
try {
const { encrypted, iv } = await encryptToken(refreshToken);
const now = new Date().toISOString();
const tokenExpiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_MS).toISOString();
const existingAccounts = get().savedAccounts;
const existingIndex = existingAccounts.findIndex((acc) => acc.id === user.id);
let updatedList: SavedAccount[] = [];
if (existingIndex >= 0) {
// Update existing entry and set to current
updatedList = existingAccounts.map((acc, index) => {
if (index === existingIndex) {
return {
...acc,
email: user.email,
phoneNumber: user.phoneNumber,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
avatarPositionX: user.avatarPositionX,
avatarPositionY: user.avatarPositionY,
roleName: user.role?.name || "USER",
encryptedRefreshToken: encrypted,
tokenIv: iv,
tokenExpiresAt,
lastActiveAt: now,
isCurrent: true,
isExpired: false,
};
}
return { ...acc, isCurrent: false };
});
} else {
// New account
const otherAccounts = existingAccounts.map((acc) => ({ ...acc, isCurrent: false }));
// Apply LRU Eviction if max accounts reached (evict non-current account with oldest lastActiveAt)
if (otherAccounts.length >= MAX_SAVED_ACCOUNTS) {
otherAccounts.sort((a, b) => new Date(a.lastActiveAt).getTime() - new Date(b.lastActiveAt).getTime());
// Remove the oldest
otherAccounts.shift();
}
const newEntry: SavedAccount = {
id: user.id,
email: user.email,
phoneNumber: user.phoneNumber,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
avatarPositionX: user.avatarPositionX,
avatarPositionY: user.avatarPositionY,
roleName: user.role?.name || "USER",
encryptedRefreshToken: encrypted,
tokenIv: iv,
tokenExpiresAt,
lastActiveAt: now,
isCurrent: true,
isExpired: false,
};
updatedList = [newEntry, ...otherAccounts];
}
// Sort by lastActiveAt descending
updatedList.sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime());
set({ savedAccounts: updatedList });
persistSavedAccounts(updatedList);
} catch (err) {
console.error("[AccountStore] addOrUpdateAccount error:", err);
}
},
updateActiveRefreshToken: async (refreshToken: string) => {
if (!refreshToken) return;
try {
const { encrypted, iv } = await encryptToken(refreshToken);
const tokenExpiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_MS).toISOString();
const now = new Date().toISOString();
const updatedList = get().savedAccounts.map((acc) => {
if (acc.isCurrent) {
return {
...acc,
encryptedRefreshToken: encrypted,
tokenIv: iv,
tokenExpiresAt,
lastActiveAt: now,
isExpired: false,
};
}
return acc;
});
set({ savedAccounts: updatedList });
persistSavedAccounts(updatedList);
} catch (err) {
console.warn("[AccountStore] updateActiveRefreshToken error:", err);
}
},
switchAccount: async (userId: string) => {
const account = get().savedAccounts.find((acc) => acc.id === userId);
if (!account) {
return { success: false, error: "Tài khoản không tồn tại" };
}
if (account.isCurrent) {
return { success: true };
}
set({
isSwitching: true,
targetSwitchUser: {
id: account.id,
fullName: account.fullName || undefined,
email: account.email,
avatarUrl: account.avatarUrl,
avatarPositionX: account.avatarPositionX,
avatarPositionY: account.avatarPositionY,
},
});
try {
// 1. Decrypt refresh token
const rawRefreshToken = await decryptToken(account.encryptedRefreshToken, account.tokenIv);
// 2. Call backend /auth/switch
const response = await authService.switchAccount({ refreshToken: rawRefreshToken });
if (response.success && response.data) {
const { user, accessToken, refreshToken } = response.data;
// 3. Encrypt new rotated refresh token
const { encrypted, iv } = await encryptToken(refreshToken);
const now = new Date().toISOString();
const tokenExpiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_MS).toISOString();
// 4. Update saved accounts list
const updatedList = get().savedAccounts.map((acc) => {
if (acc.id === user.id) {
return {
...acc,
email: user.email,
phoneNumber: user.phoneNumber,
fullName: user.fullName,
avatarUrl: user.avatarUrl,
avatarPositionX: user.avatarPositionX,
avatarPositionY: user.avatarPositionY,
roleName: user.role?.name || "USER",
encryptedRefreshToken: encrypted,
tokenIv: iv,
tokenExpiresAt,
lastActiveAt: now,
isCurrent: true,
isExpired: false,
};
}
return { ...acc, isCurrent: false };
});
updatedList.sort((a, b) => new Date(b.lastActiveAt).getTime() - new Date(a.lastActiveAt).getTime());
// 5. Purge React Query cache so no previous data leaks
queryClient.clear();
// 6. Update Auth Store
useAuthStore.getState().setAuth(user, accessToken, refreshToken);
set({
savedAccounts: updatedList,
isSwitching: false,
targetSwitchUser: null,
});
persistSavedAccounts(updatedList);
return { success: true };
} else {
throw new Error(response.message || "Switch failed");
}
} catch (error: any) {
const is401 = error?.response?.status === 401;
const isDecryptionFailed = error?.message === "DECRYPTION_FAILED";
if (is401 || isDecryptionFailed) {
// Mark account as expired so user can re-authenticate
const updatedList = get().savedAccounts.map((acc) => {
if (acc.id === userId) {
return { ...acc, isExpired: true };
}
return acc;
});
set({
savedAccounts: updatedList,
isSwitching: false,
targetSwitchUser: null,
});
persistSavedAccounts(updatedList);
return { success: false, error: "EXPIRED" };
}
set({
isSwitching: false,
targetSwitchUser: null,
});
return {
success: false,
error: error?.message || "Lỗi chuyển đổi tài khoản",
};
}
},
removeSavedAccount: async (userId: string) => {
const list = get().savedAccounts.filter((acc) => acc.id !== userId);
set({ savedAccounts: list });
persistSavedAccounts(list);
},
logoutCurrentAccount: async () => {
try {
await authService.logout();
} catch {
// Ignore logout API failures
}
const currentAccounts = get().savedAccounts;
const currentActive = currentAccounts.find((acc) => acc.isCurrent);
const remaining = currentAccounts.filter((acc) => acc.id !== currentActive?.id);
set({ savedAccounts: remaining });
persistSavedAccounts(remaining);
queryClient.clear();
if (remaining.length > 0) {
// Auto-switch to next available account
const nextAccount = remaining[0];
const result = await get().switchAccount(nextAccount.id);
if (result.success) {
return true;
}
}
// No remaining accounts or auto-switch failed
useAuthStore.getState().clearAuth();
return false;
},
clearAllAccounts: () => {
set({ savedAccounts: [], isSwitching: false, targetSwitchUser: null });
safeStorage.removeItem(SAVED_ACCOUNTS_STORAGE_KEY);
queryClient.clear();
useAuthStore.getState().clearAuth();
},
}));
import { User } from "./auth";
export interface SavedAccount {
id: string; // userId (UUID)
email: string | null;
phoneNumber?: string | null;
fullName: string | null;
avatarUrl?: string | null;
avatarPositionX?: number;
avatarPositionY?: number;
roleName: string;
encryptedRefreshToken: string; // Encrypted with AES-GCM
tokenIv: string; // Initialization vector in hex
tokenExpiresAt: string; // ISO string (7 days)
lastActiveAt: string; // ISO string (used for LRU eviction)
isCurrent: boolean;
isExpired?: boolean;
}
export interface AccountSwitcherState {
savedAccounts: SavedAccount[];
isSwitching: boolean;
targetSwitchUser: {
id?: string;
fullName?: string;
email?: string | null;
avatarUrl?: string | null;
avatarPositionX?: number;
avatarPositionY?: number;
} | null;
addOrUpdateAccount: (user: User, refreshToken: string) => Promise<void>;
updateActiveRefreshToken: (refreshToken: string) => Promise<void>;
switchAccount: (userId: string) => Promise<{ success: boolean; error?: string }>;
removeSavedAccount: (userId: string) => Promise<void>;
logoutCurrentAccount: () => Promise<boolean>;
clearAllAccounts: () => void;
}
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