): string => {
- const keys = key.split('.');
- let value: any = messages;
-
- // EN: Navigate through nested object
- // VI: Điều hướng qua nested object
- for (const k of keys) {
- if (value && typeof value === 'object' && k in value) {
- value = value[k];
- } else {
- // EN: Return key if translation not found (fallback)
- // VI: Trả về key nếu không tìm thấy translation (fallback)
- console.warn(`Translation missing for key: ${key} in locale: ${locale}`);
- return key;
- }
- }
-
- // EN: Return the translation if it's a string
- // VI: Trả về translation nếu là string
- if (typeof value === 'string') {
- // EN: Simple interpolation for {variable} placeholders
- // VI: Interpolation đơn giản cho placeholders {variable}
- if (values) {
- return Object.entries(values).reduce((str, [key, val]) => {
- return str.replace(new RegExp(`{${key}}`, 'g'), String(val));
- }, value);
- }
- return value;
- }
-
- return key;
- };
-
- return {
- /**
- * EN: Translation function / VI: Hàm translation
- */
- t,
- /**
- * EN: Current locale / VI: Locale hiện tại
- */
- locale,
- /**
- * EN: Set locale function / VI: Hàm đặt locale
- */
- setLocale,
- };
-}
diff --git a/apps/web-client/src/features/shared/middleware/auth-guard.tsx b/apps/web-client/src/features/shared/middleware/auth-guard.tsx
new file mode 100644
index 00000000..b9238723
--- /dev/null
+++ b/apps/web-client/src/features/shared/middleware/auth-guard.tsx
@@ -0,0 +1,211 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { useAuthStore } from '../../../stores/auth-store';
+import { Role } from '@goodgo/types';
+import { Card } from '../components/ui/card';
+import { Button } from '../ui/button';
+
+/**
+ * EN: Auth Guard Props
+ * VI: Props cho Auth Guard
+ */
+interface AuthGuardProps {
+ /** EN: Content to render when authenticated / VI: Nội dung để render khi đã xác thực */
+ children: React.ReactNode;
+ /** EN: Required authentication / VI: Yêu cầu xác thực */
+ requireAuth?: boolean;
+ /** EN: Required roles for access / VI: Vai trò yêu cầu để truy cập */
+ requiredRoles?: Role[];
+ /** EN: Redirect path when not authenticated / VI: Đường dẫn redirect khi chưa xác thực */
+ redirectTo?: string;
+ /** EN: Fallback component while checking auth / VI: Component fallback trong khi kiểm tra auth */
+ fallback?: React.ReactNode;
+ /** EN: Whether to check on client side only / VI: Có chỉ kiểm tra ở client side không */
+ clientOnly?: boolean;
+}
+
+/**
+ * EN: Auth Guard Component - Protects routes based on authentication and role requirements
+ * VI: Auth Guard Component - Bảo vệ routes dựa trên xác thực và yêu cầu vai trò
+ *
+ * Features:
+ * - Client-side authentication checking
+ * - Role-based access control
+ * - Automatic redirects
+ * - Loading states
+ * - Custom fallback components
+ *
+ * @example
+ * ```tsx
+ * // Require authentication
+ *
+ *
+ *
+ *
+ * // Require specific role
+ *
+ *
+ *
+ *
+ * // Require multiple roles
+ *
+ *
+ *
+ * ```
+ */
+export function AuthGuard({
+ children,
+ requireAuth = true,
+ requiredRoles = [],
+ redirectTo,
+ fallback,
+ clientOnly = true,
+}: AuthGuardProps) {
+ const router = useRouter();
+ const { user, isAuthenticated, isLoading, fetchUser } = useAuthStore();
+ const [isChecking, setIsChecking] = useState(true);
+
+ useEffect(() => {
+ // If clientOnly is true, skip server-side checks
+ if (clientOnly) {
+ setIsChecking(false);
+ return;
+ }
+
+ // Fetch user data if not loaded
+ if (!user && !isLoading) {
+ fetchUser().finally(() => setIsChecking(false));
+ } else {
+ setIsChecking(false);
+ }
+ }, [user, isLoading, fetchUser, clientOnly]);
+
+ // Show loading state
+ if (isChecking || isLoading) {
+ if (fallback) {
+ return <>{fallback}>;
+ }
+
+ return (
+
+ );
+ }
+
+ // Check authentication requirement
+ if (requireAuth && !isAuthenticated) {
+ // Redirect to login or custom path
+ const redirectPath = redirectTo || '/auth/login';
+ if (typeof window !== 'undefined') {
+ router.push(redirectPath);
+ }
+ return null;
+ }
+
+ // Check role requirements
+ if (requiredRoles.length > 0 && user) {
+ const hasRequiredRole = requiredRoles.includes(user.role);
+ if (!hasRequiredRole) {
+ // Show access denied
+ return (
+
+
+ Access Denied
+
+ You don't have permission to access this resource.
+
+
+
+
+
+
+
+ );
+ }
+ }
+
+ // All checks passed, render children
+ return <>{children}>;
+}
+
+/**
+ * EN: Require Auth HOC - Higher-order component for authentication
+ * VI: Require Auth HOC - Higher-order component cho xác thực
+ *
+ * @example
+ * ```tsx
+ * const ProtectedPage = requireAuth(MyComponent);
+ * const AdminPage = requireAuth(MyComponent, [Role.ADMIN]);
+ * ```
+ */
+export function requireAuth(
+ Component: React.ComponentType
,
+ requiredRoles?: Role[]
+) {
+ return function AuthenticatedComponent(props: P) {
+ return (
+
+
+
+ );
+ };
+}
+
+/**
+ * EN: Role-based guard components
+ * VI: Components guard dựa trên vai trò
+ */
+export const RequireAdmin: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+
+ {children}
+
+);
+
+export const RequireSuperAdmin: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+
+ {children}
+
+);
+
+/**
+ * EN: Utility functions for role checking
+ * VI: Hàm utility để kiểm tra vai trò
+ */
+export const hasRole = (userRole: Role | undefined, requiredRoles: Role[]): boolean => {
+ if (!userRole) return false;
+ return requiredRoles.includes(userRole);
+};
+
+export const hasAdminRole = (userRole: Role | undefined): boolean => {
+ return hasRole(userRole, [Role.ADMIN, Role.SUPER_ADMIN]);
+};
+
+export const hasSuperAdminRole = (userRole: Role | undefined): boolean => {
+ return hasRole(userRole, [Role.SUPER_ADMIN]);
+};
+
+export const canManageUsers = (userRole: Role | undefined): boolean => {
+ return hasRole(userRole, [Role.ADMIN, Role.SUPER_ADMIN]);
+};
+
+export const canDeleteUsers = (currentUserRole: Role | undefined, targetUserRole: Role): boolean => {
+ if (!currentUserRole) return false;
+
+ // Super admin can delete anyone
+ if (currentUserRole === Role.SUPER_ADMIN) return true;
+
+ // Admin can delete users but not other admins or super admins
+ if (currentUserRole === Role.ADMIN) {
+ return targetUserRole === Role.USER;
+ }
+
+ // Users cannot delete anyone
+ return false;
+};
\ No newline at end of file
diff --git a/apps/web-client/src/features/theme/components/language-switcher.tsx b/apps/web-client/src/features/theme/components/language-switcher.tsx
index 2cfcec51..e8cf191c 100644
--- a/apps/web-client/src/features/theme/components/language-switcher.tsx
+++ b/apps/web-client/src/features/theme/components/language-switcher.tsx
@@ -9,7 +9,7 @@ import {
DropdownMenuItem
} from '@/features/shared/components/ui/dropdown-menu';
import { cn } from '@/shared/lib/utils';
-import { useTranslation } from '@/shared/hooks/use-translation';
+import { useI18n } from '../i18n-context';
/**
* EN: Language configuration
diff --git a/apps/web-client/src/features/theme/i18n-config.ts b/apps/web-client/src/features/theme/i18n-config.ts
index 625757ab..d9b0d01f 100644
--- a/apps/web-client/src/features/theme/i18n-config.ts
+++ b/apps/web-client/src/features/theme/i18n-config.ts
@@ -15,6 +15,12 @@ export const locales = ['en', 'vi'] as const;
*/
export const defaultLocale = 'en' as const;
+/**
+ * EN: Default timezone for consistent date/time formatting
+ * VI: Timezone mặc định để format date/time nhất quán
+ */
+export const defaultTimeZone = 'UTC' as const;
+
/**
* EN: Locale type
* VI: Kiểu locale
diff --git a/apps/web-client/src/features/theme/i18n-context.tsx b/apps/web-client/src/features/theme/i18n-context.tsx
index 051722b4..82a71b4c 100644
--- a/apps/web-client/src/features/theme/i18n-context.tsx
+++ b/apps/web-client/src/features/theme/i18n-context.tsx
@@ -31,7 +31,7 @@ interface I18nContextType {
* EN: i18n Context
* VI: Context i18n
*/
-const I18nContext = React.createContext(undefined);
+export const I18nContext = React.createContext(undefined);
/**
* EN: Get locale from localStorage or browser
diff --git a/apps/web-client/src/features/theme/i18n-provider.tsx b/apps/web-client/src/features/theme/i18n-provider.tsx
index 763d46ba..1a418537 100644
--- a/apps/web-client/src/features/theme/i18n-provider.tsx
+++ b/apps/web-client/src/features/theme/i18n-provider.tsx
@@ -8,30 +8,44 @@
*/
import { NextIntlClientProvider } from 'next-intl';
-import { I18nProvider as CustomI18nProvider } from './i18n-context';
-import { useI18n } from './i18n-context';
-import { useMemo } from 'react';
+import { I18nContext } from './i18n-context';
+import { defaultTimeZone } from './i18n-config';
+import * as React from 'react';
import enMessages from '../shared/i18n/en.json';
import viMessages from '../shared/i18n/vi.json';
/**
- * EN: Inner provider that uses the locale from context
- * VI: Provider bên trong sử dụng locale từ context
+ * EN: Get locale from localStorage or browser (duplicate from i18n-context to avoid circular dependency)
+ * VI: Lấy locale từ localStorage hoặc browser (duplicate từ i18n-context để tránh circular dependency)
*/
-function NextIntlProviderWrapper({ children }: { children: React.ReactNode }) {
- const { locale } = useI18n();
+function getStoredLocale(): 'en' | 'vi' {
+ if (typeof window === 'undefined') {
+ return 'en'; // defaultLocale
+ }
- // EN: Get messages based on locale - use static imports for immediate availability / VI: Lấy messages dựa trên locale - sử dụng static imports để có sẵn ngay
- const messages = useMemo(() => {
- return locale === 'vi' ? viMessages : enMessages;
- }, [locale]);
+ // EN: Try to get from localStorage preferences / VI: Thử lấy từ localStorage preferences
+ try {
+ const stored = localStorage.getItem('preferences');
+ if (stored) {
+ const parsed = JSON.parse(stored);
+ if (parsed.language && (parsed.language === 'en' || parsed.language === 'vi')) {
+ return parsed.language;
+ }
+ }
+ } catch {
+ // EN: Invalid stored data, continue to browser detection / VI: Dữ liệu lưu không hợp lệ, tiếp tục detect browser
+ }
- // EN: Always render NextIntlClientProvider to ensure context exists / VI: Luôn render NextIntlClientProvider để đảm bảo context tồn tại
- return (
-
- {children}
-
- );
+ // EN: Detect from browser language / VI: Phát hiện từ ngôn ngữ browser
+ if (typeof navigator !== 'undefined') {
+ const browserLang = navigator.language || navigator.languages?.[0] || '';
+ const langCode = browserLang.split('-')[0].toLowerCase();
+ if (langCode === 'vi') {
+ return 'vi';
+ }
+ }
+
+ return 'en'; // defaultLocale
}
/**
@@ -39,9 +53,75 @@ function NextIntlProviderWrapper({ children }: { children: React.ReactNode }) {
* VI: Component I18n Provider chính
*/
export function I18nProvider({ children }: { children: React.ReactNode }) {
+ // EN: Get initial locale / VI: Lấy locale ban đầu
+ const [locale, setLocaleState] = React.useState<'en' | 'vi'>(() => getStoredLocale());
+
+ /**
+ * EN: Set locale and persist to localStorage
+ * VI: Đặt locale và lưu vào localStorage
+ */
+ const setLocale = React.useCallback((newLocale: 'en' | 'vi') => {
+ if (newLocale !== 'en' && newLocale !== 'vi') {
+ console.warn(`Invalid locale: ${newLocale}`);
+ return;
+ }
+
+ setLocaleState(newLocale);
+
+ // EN: Update localStorage preferences / VI: Cập nhật localStorage preferences
+ if (typeof window !== 'undefined') {
+ try {
+ const stored = localStorage.getItem('preferences');
+ const preferences = stored ? JSON.parse(stored) : {};
+ preferences.language = newLocale;
+ localStorage.setItem('preferences', JSON.stringify(preferences));
+ } catch (error) {
+ console.error('Failed to save locale preference:', error);
+ }
+
+ // EN: Update HTML lang attribute / VI: Cập nhật thuộc tính lang của HTML
+ document.documentElement.lang = newLocale;
+ }
+ }, []);
+
+ /**
+ * EN: Get current locale
+ * VI: Lấy locale hiện tại
+ */
+ const getLocale = React.useCallback(() => {
+ return locale;
+ }, [locale]);
+
+ // EN: Initialize HTML lang attribute on mount / VI: Khởi tạo thuộc tính lang của HTML khi mount
+ React.useEffect(() => {
+ if (typeof document !== 'undefined') {
+ document.documentElement.lang = locale;
+ }
+ }, [locale]);
+
+ // EN: Get messages based on locale / VI: Lấy messages dựa trên locale
+ const messages = React.useMemo(() => {
+ return locale === 'vi' ? viMessages : enMessages;
+ }, [locale]);
+
+ const customContextValue = React.useMemo(
+ () => ({
+ locale,
+ setLocale,
+ getLocale,
+ }),
+ [locale, setLocale, getLocale]
+ );
+
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/apps/web-client/src/lib/api/users.ts b/apps/web-client/src/lib/api/users.ts
new file mode 100644
index 00000000..64db6cff
--- /dev/null
+++ b/apps/web-client/src/lib/api/users.ts
@@ -0,0 +1,125 @@
+import { UserResponse, CreateUserDto, UpdateUserDto, Role } from '@goodgo/types';
+import { apiClient } from '../../services/api/client';
+
+/**
+ * EN: Query parameters for users list endpoint
+ * VI: Tham số query cho endpoint danh sách users
+ */
+export interface GetUsersParams {
+ /** EN: Page number for pagination / VI: Số trang cho pagination */
+ page?: number;
+ /** EN: Number of items per page / VI: Số items mỗi trang */
+ limit?: number;
+ /** EN: Search query for filtering users / VI: Query tìm kiếm để lọc users */
+ search?: string;
+ /** EN: Filter by user role / VI: Lọc theo vai trò user */
+ role?: Role;
+ /** EN: Filter by active status / VI: Lọc theo trạng thái active */
+ isActive?: boolean;
+ /** EN: Sort field / VI: Trường sắp xếp */
+ sortBy?: 'email' | 'createdAt' | 'updatedAt';
+ /** EN: Sort direction / VI: Hướng sắp xếp */
+ sortOrder?: 'asc' | 'desc';
+}
+
+/**
+ * EN: Response structure for paginated users list
+ * VI: Cấu trúc response cho danh sách users phân trang
+ */
+export interface GetUsersResponse {
+ /** EN: Array of user objects / VI: Mảng các objects user */
+ data: UserResponse[];
+ /** EN: Pagination metadata / VI: Metadata phân trang */
+ pagination: {
+ /** EN: Current page number / VI: Số trang hiện tại */
+ page: number;
+ /** EN: Items per page / VI: Items mỗi trang */
+ limit: number;
+ /** EN: Total number of items / VI: Tổng số items */
+ total: number;
+ /** EN: Total number of pages / VI: Tổng số trang */
+ totalPages: number;
+ };
+}
+
+/**
+ * EN: Fetch paginated list of users
+ * VI: Lấy danh sách users phân trang
+ *
+ * @param params - Query parameters for filtering and pagination
+ * @returns Promise resolving to paginated users response
+ */
+export async function getUsers(params: GetUsersParams = {}): Promise {
+ const response = await apiClient.get('/users', { params });
+ return response.data;
+}
+
+/**
+ * EN: Fetch single user by ID
+ * VI: Lấy thông tin user theo ID
+ *
+ * @param id - User ID
+ * @returns Promise resolving to user response
+ */
+export async function getUser(id: string): Promise {
+ const response = await apiClient.get(`/users/${id}`);
+ return response.data;
+}
+
+/**
+ * EN: Create new user
+ * VI: Tạo user mới
+ *
+ * @param payload - User creation data
+ * @returns Promise resolving to created user response
+ */
+export async function createUser(payload: CreateUserDto): Promise {
+ const response = await apiClient.post('/users', payload);
+ return response.data;
+}
+
+/**
+ * EN: Update existing user
+ * VI: Cập nhật user hiện có
+ *
+ * @param id - User ID
+ * @param payload - User update data
+ * @returns Promise resolving to updated user response
+ */
+export async function updateUser(id: string, payload: UpdateUserDto): Promise {
+ const response = await apiClient.put(`/users/${id}`, payload);
+ return response.data;
+}
+
+/**
+ * EN: Delete user by ID
+ * VI: Xóa user theo ID
+ *
+ * @param id - User ID
+ * @returns Promise resolving when deletion is complete
+ */
+export async function deleteUser(id: string): Promise {
+ await apiClient.delete(`/users/${id}`);
+}
+
+/**
+ * EN: Bulk delete multiple users
+ * VI: Xóa nhiều users cùng lúc
+ *
+ * @param ids - Array of user IDs to delete
+ * @returns Promise resolving when bulk deletion is complete
+ */
+export async function bulkDeleteUsers(ids: string[]): Promise {
+ await apiClient.post('/users/bulk-delete', { ids });
+}
+
+/**
+ * EN: Bulk update user roles
+ * VI: Cập nhật vai trò cho nhiều users cùng lúc
+ *
+ * @param updates - Array of user ID and new role pairs
+ * @returns Promise resolving when bulk update is complete
+ */
+export async function bulkUpdateUserRoles(updates: Array<{ id: string; role: Role }>): Promise {
+ await apiClient.post('/users/bulk-update-roles', { updates });
+}
\ No newline at end of file
diff --git a/apps/web-client/src/stores/__tests__/users-store.test.ts b/apps/web-client/src/stores/__tests__/users-store.test.ts
new file mode 100644
index 00000000..0205e44c
--- /dev/null
+++ b/apps/web-client/src/stores/__tests__/users-store.test.ts
@@ -0,0 +1,306 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { useUsersStore } from '../users-store';
+
+// Mock the API functions
+vi.mock('../../lib/api/users', () => ({
+ getUsers: vi.fn(),
+ getUser: vi.fn(),
+ createUser: vi.fn(),
+ updateUser: vi.fn(),
+ deleteUser: vi.fn(),
+ bulkDeleteUsers: vi.fn(),
+ bulkUpdateUserRoles: vi.fn(),
+}));
+
+import {
+ getUsers,
+ getUser,
+ createUser,
+ updateUser,
+ deleteUser,
+ bulkDeleteUsers,
+ bulkUpdateUserRoles,
+} from '../../lib/api/users';
+
+/**
+ * EN: Users store unit tests
+ * VI: Unit tests cho users store
+ */
+describe('UsersStore', () => {
+ beforeEach(() => {
+ // Reset store state before each test
+ useUsersStore.setState({
+ users: [],
+ currentUser: null,
+ pagination: null,
+ isLoading: false,
+ isLoadingUser: false,
+ error: null,
+ });
+
+ // Reset all mocks
+ vi.clearAllMocks();
+ });
+
+ describe('initial state', () => {
+ it('initializes with default state', () => {
+ const state = useUsersStore.getState();
+ expect(state.users).toEqual([]);
+ expect(state.currentUser).toBeNull();
+ expect(state.pagination).toBeNull();
+ expect(state.isLoading).toBe(false);
+ expect(state.isLoadingUser).toBe(false);
+ expect(state.error).toBeNull();
+ });
+ });
+
+ describe('fetchUsers', () => {
+ it('sets loading state and fetches users successfully', async () => {
+ const mockResponse = {
+ data: [
+ {
+ id: '1',
+ email: 'user1@example.com',
+ role: 'USER',
+ isActive: true,
+ createdAt: '2024-01-01T00:00:00Z',
+ updatedAt: '2024-01-01T00:00:00Z',
+ },
+ ],
+ pagination: {
+ page: 1,
+ limit: 10,
+ total: 1,
+ totalPages: 1,
+ },
+ };
+
+ (getUsers as any).mockResolvedValue(mockResponse);
+
+ const { fetchUsers } = useUsersStore.getState();
+ await fetchUsers();
+
+ const state = useUsersStore.getState();
+ expect(state.isLoading).toBe(false);
+ expect(state.users).toEqual(mockResponse.data);
+ expect(state.pagination).toEqual(mockResponse.pagination);
+ expect(state.error).toBeNull();
+ });
+
+ it('handles fetch users error', async () => {
+ const mockError = new Error('Failed to fetch users');
+ (getUsers as any).mockRejectedValue(mockError);
+
+ const { fetchUsers } = useUsersStore.getState();
+ await expect(fetchUsers()).rejects.toThrow('Failed to fetch users');
+
+ const state = useUsersStore.getState();
+ expect(state.isLoading).toBe(false);
+ expect(state.users).toEqual([]);
+ expect(state.error).toBe('Failed to fetch users');
+ });
+ });
+
+ describe('fetchUser', () => {
+ it('fetches single user successfully', async () => {
+ const mockUser = {
+ id: '1',
+ email: 'user1@example.com',
+ role: 'USER',
+ isActive: true,
+ createdAt: '2024-01-01T00:00:00Z',
+ updatedAt: '2024-01-01T00:00:00Z',
+ };
+
+ (getUser as any).mockResolvedValue(mockUser);
+
+ const { fetchUser } = useUsersStore.getState();
+ await fetchUser('1');
+
+ const state = useUsersStore.getState();
+ expect(state.isLoadingUser).toBe(false);
+ expect(state.currentUser).toEqual(mockUser);
+ expect(state.error).toBeNull();
+ });
+
+ it('handles fetch user error', async () => {
+ const mockError = new Error('User not found');
+ (getUser as any).mockRejectedValue(mockError);
+
+ const { fetchUser } = useUsersStore.getState();
+ await expect(fetchUser('1')).rejects.toThrow('User not found');
+
+ const state = useUsersStore.getState();
+ expect(state.isLoadingUser).toBe(false);
+ expect(state.currentUser).toBeNull();
+ expect(state.error).toBe('User not found');
+ });
+ });
+
+ describe('createUser', () => {
+ it('creates user successfully and adds to list', async () => {
+ const newUser = {
+ id: '3',
+ email: 'user3@example.com',
+ role: 'USER',
+ isActive: true,
+ createdAt: '2024-01-03T00:00:00Z',
+ updatedAt: '2024-01-03T00:00:00Z',
+ };
+
+ const existingUsers = [
+ {
+ id: '1',
+ email: 'user1@example.com',
+ role: 'USER',
+ isActive: true,
+ createdAt: '2024-01-01T00:00:00Z',
+ updatedAt: '2024-01-01T00:00:00Z',
+ },
+ {
+ id: '2',
+ email: 'user2@example.com',
+ role: 'USER',
+ isActive: true,
+ createdAt: '2024-01-02T00:00:00Z',
+ updatedAt: '2024-01-02T00:00:00Z',
+ },
+ ];
+
+ // Set initial state with existing users
+ useUsersStore.setState({ users: existingUsers });
+
+ (createUser as any).mockResolvedValue(newUser);
+
+ const createData = {
+ email: 'user3@example.com',
+ password: 'password123',
+ role: 'USER' as const,
+ };
+
+ const { createUser: createUserAction } = useUsersStore.getState();
+ const result = await createUserAction(createData);
+
+ expect(result).toEqual(newUser);
+
+ const state = useUsersStore.getState();
+ expect(state.isLoadingUser).toBe(false);
+ expect(state.users).toHaveLength(3);
+ expect(state.users[0]).toEqual(newUser); // New user added at beginning
+ });
+ });
+
+ describe('updateUser', () => {
+ it('updates user successfully', async () => {
+ const existingUser = {
+ id: '1',
+ email: 'user1@example.com',
+ role: 'USER',
+ isActive: true,
+ createdAt: '2024-01-01T00:00:00Z',
+ updatedAt: '2024-01-01T00:00:00Z',
+ };
+
+ const updatedUser = {
+ ...existingUser,
+ email: 'updated@example.com',
+ role: 'ADMIN',
+ };
+
+ useUsersStore.setState({
+ users: [existingUser],
+ currentUser: existingUser,
+ });
+
+ (updateUser as any).mockResolvedValue(updatedUser);
+
+ const updateData = {
+ email: 'updated@example.com',
+ role: 'ADMIN' as const,
+ isActive: true,
+ };
+
+ const { updateUser: updateUserAction } = useUsersStore.getState();
+ const result = await updateUserAction('1', updateData);
+
+ expect(result).toEqual(updatedUser);
+
+ const state = useUsersStore.getState();
+ expect(state.isLoadingUser).toBe(false);
+ expect(state.users[0]).toEqual(updatedUser);
+ expect(state.currentUser).toEqual(updatedUser);
+ });
+ });
+
+ describe('deleteUser', () => {
+ it('deletes user successfully', async () => {
+ const userToDelete = {
+ id: '2',
+ email: 'user2@example.com',
+ role: 'USER',
+ isActive: true,
+ createdAt: '2024-01-02T00:00:00Z',
+ updatedAt: '2024-01-02T00:00:00Z',
+ };
+
+ const users = [
+ {
+ id: '1',
+ email: 'user1@example.com',
+ role: 'USER',
+ isActive: true,
+ createdAt: '2024-01-01T00:00:00Z',
+ updatedAt: '2024-01-01T00:00:00Z',
+ },
+ userToDelete,
+ ];
+
+ useUsersStore.setState({ users, currentUser: userToDelete });
+
+ (deleteUser as any).mockResolvedValue(undefined);
+
+ const { deleteUser: deleteUserAction } = useUsersStore.getState();
+ await deleteUserAction('2');
+
+ const state = useUsersStore.getState();
+ expect(state.isLoadingUser).toBe(false);
+ expect(state.users).toHaveLength(1);
+ expect(state.users[0].id).toBe('1');
+ expect(state.currentUser).toBeNull();
+ });
+ });
+
+ describe('error handling', () => {
+ it('clears error state', () => {
+ useUsersStore.setState({ error: 'Test error' });
+ const { clearError } = useUsersStore.getState();
+
+ clearError();
+
+ const state = useUsersStore.getState();
+ expect(state.error).toBeNull();
+ });
+
+ it('resets store to initial state', () => {
+ useUsersStore.setState({
+ users: [{ id: '1', email: 'test@example.com', role: 'USER', isActive: true, createdAt: '', updatedAt: '' }],
+ currentUser: { id: '1', email: 'test@example.com', role: 'USER', isActive: true, createdAt: '', updatedAt: '' },
+ pagination: { page: 1, limit: 10, total: 1, totalPages: 1 },
+ isLoading: true,
+ isLoadingUser: true,
+ error: 'Test error',
+ });
+
+ const { reset } = useUsersStore.getState();
+ reset();
+
+ const state = useUsersStore.getState();
+ expect(state.users).toEqual([]);
+ expect(state.currentUser).toBeNull();
+ expect(state.pagination).toBeNull();
+ expect(state.isLoading).toBe(false);
+ expect(state.isLoadingUser).toBe(false);
+ expect(state.error).toBeNull();
+ });
+ });
+});
\ No newline at end of file
diff --git a/apps/web-client/src/stores/users-store.ts b/apps/web-client/src/stores/users-store.ts
new file mode 100644
index 00000000..c26f50e8
--- /dev/null
+++ b/apps/web-client/src/stores/users-store.ts
@@ -0,0 +1,317 @@
+import { UserResponse, CreateUserDto, UpdateUserDto, Role } from '@goodgo/types';
+import { create } from 'zustand';
+import { devtools } from 'zustand/middleware';
+
+import {
+ getUsers,
+ getUser,
+ createUser,
+ updateUser,
+ deleteUser,
+ bulkDeleteUsers,
+ bulkUpdateUserRoles,
+ GetUsersParams,
+ GetUsersResponse
+} from '../lib/api/users';
+
+/**
+ * EN: Users store state interface
+ * VI: Interface trạng thái users store
+ */
+interface UsersState {
+ /** EN: Paginated users list / VI: Danh sách users phân trang */
+ users: UserResponse[];
+ /** EN: Single user data for detail view / VI: Dữ liệu user đơn lẻ cho view chi tiết */
+ currentUser: UserResponse | null;
+ /** EN: Pagination metadata / VI: Metadata phân trang */
+ pagination: GetUsersResponse['pagination'] | null;
+ /** EN: Loading state for list operations / VI: Trạng thái loading cho operations list */
+ isLoading: boolean;
+ /** EN: Loading state for single user operations / VI: Trạng thái loading cho operations single user */
+ isLoadingUser: boolean;
+ /** EN: Error message if any operation fails / VI: Thông báo lỗi nếu có operation thất bại */
+ error: string | null;
+
+ /** EN: Fetch paginated users list / VI: Lấy danh sách users phân trang */
+ fetchUsers: (params?: GetUsersParams) => Promise;
+ /** EN: Fetch single user by ID / VI: Lấy user đơn lẻ theo ID */
+ fetchUser: (id: string) => Promise;
+ /** EN: Create new user / VI: Tạo user mới */
+ createUser: (payload: CreateUserDto) => Promise;
+ /** EN: Update existing user / VI: Cập nhật user hiện có */
+ updateUser: (id: string, payload: UpdateUserDto) => Promise;
+ /** EN: Delete user by ID / VI: Xóa user theo ID */
+ deleteUser: (id: string) => Promise;
+ /** EN: Bulk delete multiple users / VI: Xóa nhiều users cùng lúc */
+ bulkDeleteUsers: (ids: string[]) => Promise;
+ /** EN: Bulk update user roles / VI: Cập nhật vai trò cho nhiều users */
+ bulkUpdateUserRoles: (updates: Array<{ id: string; role: Role }>) => Promise;
+ /** EN: Clear current user data / VI: Xóa dữ liệu current user */
+ clearCurrentUser: () => void;
+ /** EN: Clear error state / VI: Xóa trạng thái lỗi */
+ clearError: () => void;
+ /** EN: Reset store to initial state / VI: Reset store về trạng thái ban đầu */
+ reset: () => void;
+}
+
+/**
+ * EN: Initial state for users store
+ * VI: Trạng thái ban đầu cho users store
+ */
+const initialState = {
+ users: [],
+ currentUser: null,
+ pagination: null,
+ isLoading: false,
+ isLoadingUser: false,
+ error: null,
+};
+
+/**
+ * EN: Zustand store for users state management
+ * VI: Zustand store để quản lý trạng thái users
+ *
+ * Features:
+ * - Paginated users list management
+ * - Single user operations (CRUD)
+ * - Bulk operations support
+ * - Loading and error states
+ * - DevTools integration for debugging
+ */
+export const useUsersStore = create()(
+ devtools(
+ (set, get) => ({
+ ...initialState,
+
+ /**
+ * EN: Fetch paginated users list
+ * VI: Lấy danh sách users phân trang
+ */
+ fetchUsers: async (params = {}) => {
+ set({ isLoading: true, error: null });
+
+ try {
+ const response = await getUsers(params);
+ set({
+ users: response.data,
+ pagination: response.pagination,
+ isLoading: false,
+ });
+ } catch (error: any) {
+ const errorMessage = error?.response?.data?.message || error?.message || 'Failed to fetch users';
+ set({
+ error: errorMessage,
+ isLoading: false,
+ users: [],
+ pagination: null,
+ });
+ throw error;
+ }
+ },
+
+ /**
+ * EN: Fetch single user by ID
+ * VI: Lấy user đơn lẻ theo ID
+ */
+ fetchUser: async (id: string) => {
+ set({ isLoadingUser: true, error: null });
+
+ try {
+ const user = await getUser(id);
+ set({
+ currentUser: user,
+ isLoadingUser: false,
+ });
+ } catch (error: any) {
+ const errorMessage = error?.response?.data?.message || error?.message || 'Failed to fetch user';
+ set({
+ error: errorMessage,
+ isLoadingUser: false,
+ currentUser: null,
+ });
+ throw error;
+ }
+ },
+
+ /**
+ * EN: Create new user
+ * VI: Tạo user mới
+ */
+ createUser: async (payload: CreateUserDto) => {
+ set({ isLoadingUser: true, error: null });
+
+ try {
+ const newUser = await createUser(payload);
+
+ // Add to users list if we have one
+ const { users, pagination } = get();
+ if (users.length > 0 && pagination) {
+ set({
+ users: [newUser, ...users.slice(0, -1)], // Add to beginning, remove last to maintain page size
+ isLoadingUser: false,
+ });
+ } else {
+ set({ isLoadingUser: false });
+ }
+
+ return newUser;
+ } catch (error: any) {
+ const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create user';
+ set({
+ error: errorMessage,
+ isLoadingUser: false,
+ });
+ throw error;
+ }
+ },
+
+ /**
+ * EN: Update existing user
+ * VI: Cập nhật user hiện có
+ */
+ updateUser: async (id: string, payload: UpdateUserDto) => {
+ set({ isLoadingUser: true, error: null });
+
+ try {
+ const updatedUser = await updateUser(id, payload);
+
+ // Update in users list if present
+ const { users } = get();
+ const updatedUsers = users.map(user =>
+ user.id === id ? updatedUser : user
+ );
+
+ set({
+ users: updatedUsers,
+ currentUser: updatedUser,
+ isLoadingUser: false,
+ });
+
+ return updatedUser;
+ } catch (error: any) {
+ const errorMessage = error?.response?.data?.message || error?.message || 'Failed to update user';
+ set({
+ error: errorMessage,
+ isLoadingUser: false,
+ });
+ throw error;
+ }
+ },
+
+ /**
+ * EN: Delete user by ID
+ * VI: Xóa user theo ID
+ */
+ deleteUser: async (id: string) => {
+ set({ isLoadingUser: true, error: null });
+
+ try {
+ await deleteUser(id);
+
+ // Remove from users list
+ const { users } = get();
+ const filteredUsers = users.filter(user => user.id !== id);
+
+ set({
+ users: filteredUsers,
+ currentUser: null,
+ isLoadingUser: false,
+ });
+ } catch (error: any) {
+ const errorMessage = error?.response?.data?.message || error?.message || 'Failed to delete user';
+ set({
+ error: errorMessage,
+ isLoadingUser: false,
+ });
+ throw error;
+ }
+ },
+
+ /**
+ * EN: Bulk delete multiple users
+ * VI: Xóa nhiều users cùng lúc
+ */
+ bulkDeleteUsers: async (ids: string[]) => {
+ set({ isLoading: true, error: null });
+
+ try {
+ await bulkDeleteUsers(ids);
+
+ // Remove from users list
+ const { users } = get();
+ const filteredUsers = users.filter(user => !ids.includes(user.id));
+
+ set({
+ users: filteredUsers,
+ isLoading: false,
+ });
+ } catch (error: any) {
+ const errorMessage = error?.response?.data?.message || error?.message || 'Failed to bulk delete users';
+ set({
+ error: errorMessage,
+ isLoading: false,
+ });
+ throw error;
+ }
+ },
+
+ /**
+ * EN: Bulk update user roles
+ * VI: Cập nhật vai trò cho nhiều users
+ */
+ bulkUpdateUserRoles: async (updates: Array<{ id: string; role: Role }>) => {
+ set({ isLoading: true, error: null });
+
+ try {
+ await bulkUpdateUserRoles(updates);
+
+ // Update in users list
+ const { users } = get();
+ const updatedUsers = users.map(user => {
+ const update = updates.find(u => u.id === user.id);
+ return update ? { ...user, role: update.role } : user;
+ });
+
+ set({
+ users: updatedUsers,
+ isLoading: false,
+ });
+ } catch (error: any) {
+ const errorMessage = error?.response?.data?.message || error?.message || 'Failed to bulk update user roles';
+ set({
+ error: errorMessage,
+ isLoading: false,
+ });
+ throw error;
+ }
+ },
+
+ /**
+ * EN: Clear current user data
+ * VI: Xóa dữ liệu current user
+ */
+ clearCurrentUser: () => {
+ set({ currentUser: null });
+ },
+
+ /**
+ * EN: Clear error state
+ * VI: Xóa trạng thái lỗi
+ */
+ clearError: () => {
+ set({ error: null });
+ },
+
+ /**
+ * EN: Reset store to initial state
+ * VI: Reset store về trạng thái ban đầu
+ */
+ reset: () => {
+ set(initialState);
+ },
+ }),
+ {
+ name: 'users-store', // DevTools store name
+ }
+ )
+);
\ No newline at end of file