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>
);
};
This diff is collapsed.
......@@ -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}`;
}
......
This diff is collapsed.
......@@ -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