refactor: Thay thế hook dịch thuật tùy chỉnh bằng hook useTranslations từ next-intl, cập nhật các thành phần liên quan đến dịch thuật và điều hướng sau khi đăng nhập thành công.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { Button } from '@/features/shared/components/ui/button';
|
||||
import { useTranslation } from '@/shared/hooks/use-translation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: ChatInput component props interface
|
||||
@@ -94,7 +94,7 @@ export function ChatInput({
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
const t = useTranslations();
|
||||
const defaultPlaceholder = placeholder || t('chat.typeMessage');
|
||||
// EN: Reference to textarea element for auto-resize / VI: Reference đến element textarea cho auto-resize
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { useTranslation } from '@/shared/hooks/use-translation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: Chat layout component props interface
|
||||
@@ -97,7 +97,7 @@ export function ChatLayout({
|
||||
className,
|
||||
}: ChatLayoutProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Mobile: Hide sidebar by default / VI: Mobile: Ẩn sidebar mặc định
|
||||
const [mobileSidebarVisible, setMobileSidebarVisible] = React.useState(false);
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Button } from '@/features/shared/components/ui/button';
|
||||
import { Input } from '@/features/shared/components/ui/input';
|
||||
import { Avatar, AvatarFallback } from '@/features/shared/components/ui/avatar';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTranslation } from '@/shared/hooks/use-translation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useI18n } from '@/features/theme';
|
||||
|
||||
/**
|
||||
* EN: Conversation interface
|
||||
@@ -66,7 +67,8 @@ export function ConversationSidebar({
|
||||
className,
|
||||
}: ConversationSidebarProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t, locale } = useTranslation();
|
||||
const t = useTranslations();
|
||||
const { locale } = useI18n();
|
||||
// EN: Get current user from auth store / VI: Lấy user hiện tại từ auth store
|
||||
const { user } = useAuthStore();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/features/shared/components/ui/dropdown-menu';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { useTranslation } from '@/shared/hooks/use-translation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: Message role type
|
||||
@@ -89,7 +89,7 @@ export function MessageActionsMenu({
|
||||
children,
|
||||
}: MessageActionsMenuProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
const t = useTranslations();
|
||||
|
||||
// EN: Copy to clipboard handler / VI: Handler copy vào clipboard
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/features/shared/components/ui/avatar';
|
||||
import { useTranslation } from '@/shared/hooks/use-translation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useI18n } from '@/features/theme';
|
||||
|
||||
/**
|
||||
* EN: Message sender type
|
||||
@@ -330,7 +331,8 @@ export function MessageBubble({
|
||||
className,
|
||||
}: MessageBubbleProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t, locale } = useTranslation();
|
||||
const t = useTranslations();
|
||||
const { locale } = useI18n();
|
||||
|
||||
// EN: System messages - centered, simple text / VI: Tin nhắn hệ thống - căn giữa, text đơn giản
|
||||
if (sender === 'system') {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { useTranslation } from '@/shared/hooks/use-translation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
/**
|
||||
* EN: TypingIndicator component props interface
|
||||
@@ -69,7 +69,7 @@ export function TypingIndicator({
|
||||
'aria-label': ariaLabel,
|
||||
}: TypingIndicatorProps) {
|
||||
// EN: Translation hook / VI: Hook translation
|
||||
const { t } = useTranslation();
|
||||
const t = useTranslations();
|
||||
const defaultAriaLabel = ariaLabel || t('chat.typing', { defaultValue: 'AI is typing...' });
|
||||
// EN: Generate array of dot indices for rendering / VI: Tạo mảng các chỉ số chấm để render
|
||||
const dots = React.useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { DesktopLayout, DesktopLayoutProps } from './desktop-layout/desktop-layout';
|
||||
import { MobileLayout, MobileLayoutProps } from './mobile-layout/mobile-layout';
|
||||
|
||||
/**
|
||||
* EN: Responsive Layout Props - combines desktop and mobile layouts
|
||||
* VI: Responsive Layout Props - kết hợp desktop và mobile layouts
|
||||
*/
|
||||
export interface ResponsiveLayoutProps extends DesktopLayoutProps, MobileLayoutProps {
|
||||
/** EN: Force desktop layout even on mobile / VI: Buộc desktop layout ngay cả trên mobile */
|
||||
forceDesktop?: boolean;
|
||||
/** EN: Force mobile layout even on desktop / VI: Buộc mobile layout ngay cả trên desktop */
|
||||
forceMobile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Responsive Layout Component - automatically switches between desktop and mobile layouts
|
||||
* VI: Responsive Layout Component - tự động chuyển đổi giữa desktop và mobile layouts
|
||||
*
|
||||
* Features:
|
||||
* - Automatically detects screen size
|
||||
* - Uses DesktopLayout for desktop/tablet landscape
|
||||
* - Uses MobileLayout for mobile/tablet portrait
|
||||
* - Supports force overrides for testing
|
||||
* - Handles all props for both layout types
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveLayout
|
||||
* showSidebar
|
||||
* sidebar={<Sidebar />}
|
||||
* showBottomNav
|
||||
* bottomNavItems={navItems}
|
||||
* >
|
||||
* <YourContent />
|
||||
* </ResponsiveLayout>
|
||||
* ```
|
||||
*/
|
||||
export function ResponsiveLayout({
|
||||
forceDesktop = false,
|
||||
forceMobile = false,
|
||||
// Desktop props
|
||||
header,
|
||||
sidebar,
|
||||
footer,
|
||||
showSidebar = false,
|
||||
showHeader = true,
|
||||
showFooter = false,
|
||||
sidebarPosition = 'left',
|
||||
sidebarWidth = 280,
|
||||
sidebarCollapsible = false,
|
||||
sidebarCollapsed = false,
|
||||
// Mobile props
|
||||
bottomNav,
|
||||
enablePullToRefresh = false,
|
||||
onRefresh,
|
||||
showBottomNav = false,
|
||||
bottomNavItems = [],
|
||||
activeNavItem,
|
||||
onNavItemPress,
|
||||
// Common props
|
||||
children,
|
||||
className,
|
||||
}: ResponsiveLayoutProps) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceDesktop) {
|
||||
setIsMobile(false);
|
||||
return;
|
||||
}
|
||||
if (forceMobile) {
|
||||
setIsMobile(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check screen size
|
||||
const checkMobile = () => {
|
||||
// Consider mobile if screen width < 768px (md breakpoint)
|
||||
// or if device is touch-based and screen height > width (portrait mobile)
|
||||
const isSmallScreen = window.innerWidth < 768;
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
const isPortrait = window.innerHeight > window.innerWidth;
|
||||
|
||||
setIsMobile(isSmallScreen || (isTouchDevice && isPortrait));
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
window.addEventListener('orientationchange', checkMobile);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
window.removeEventListener('orientationchange', checkMobile);
|
||||
};
|
||||
}, [forceDesktop, forceMobile]);
|
||||
|
||||
if (isMobile && !forceDesktop) {
|
||||
return (
|
||||
<MobileLayout
|
||||
header={header}
|
||||
footer={footer}
|
||||
bottomNav={bottomNav}
|
||||
showHeader={showHeader}
|
||||
showFooter={showFooter}
|
||||
enablePullToRefresh={enablePullToRefresh}
|
||||
onRefresh={onRefresh}
|
||||
showBottomNav={showBottomNav}
|
||||
bottomNavItems={bottomNavItems}
|
||||
activeNavItem={activeNavItem}
|
||||
onNavItemPress={onNavItemPress}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</MobileLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopLayout
|
||||
header={header}
|
||||
sidebar={sidebar}
|
||||
footer={footer}
|
||||
showSidebar={showSidebar}
|
||||
showHeader={showHeader}
|
||||
showFooter={showFooter}
|
||||
sidebarPosition={sidebarPosition}
|
||||
sidebarWidth={sidebarWidth}
|
||||
sidebarCollapsible={sidebarCollapsible}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</DesktopLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { UserResponse, Role } from '@goodgo/types';
|
||||
import { Mail, Calendar, Shield, Edit, MoreHorizontal } from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { DropdownMenu } from '../ui/dropdown-menu';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: Props for UserCard component
|
||||
* VI: Props cho component UserCard
|
||||
*/
|
||||
export interface UserCardProps {
|
||||
/** EN: User data to display / VI: Dữ liệu user để hiển thị */
|
||||
user: UserResponse;
|
||||
/** EN: Whether the card is in compact mode / VI: Card có ở chế độ compact không */
|
||||
compact?: boolean;
|
||||
/** EN: Callback when user is edited / VI: Callback khi user được edit */
|
||||
onEdit?: (user: UserResponse) => void;
|
||||
/** EN: Callback when user status is toggled / VI: Callback khi toggle trạng thái user */
|
||||
onToggleStatus?: (user: UserResponse) => void;
|
||||
/** EN: Callback when user is deleted / VI: Callback khi user bị xóa */
|
||||
onDelete?: (user: UserResponse) => void;
|
||||
/** EN: Show admin actions (edit, delete, status toggle) / VI: Hiển thị admin actions */
|
||||
showAdminActions?: boolean;
|
||||
/** EN: Additional CSS classes / VI: CSS classes bổ sung */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: User Card Component - Displays user information in a card format
|
||||
* VI: Component User Card - Hiển thị thông tin user dưới dạng card
|
||||
*
|
||||
* Features:
|
||||
* - User avatar (email initial)
|
||||
* - Basic user info (email, role, status)
|
||||
* - Creation/update dates
|
||||
* - Admin actions (edit, delete, toggle status)
|
||||
* - Compact and full modes
|
||||
*/
|
||||
export function UserCard({
|
||||
user,
|
||||
compact = false,
|
||||
onEdit,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
showAdminActions = false,
|
||||
className,
|
||||
}: UserCardProps) {
|
||||
const getRoleColor = (role: Role) => {
|
||||
switch (role) {
|
||||
case Role.SUPER_ADMIN:
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case Role.ADMIN:
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case Role.USER:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (isActive: boolean) => {
|
||||
return isActive
|
||||
? 'text-green-600 bg-green-100'
|
||||
: 'text-gray-600 bg-gray-100';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Card className={cn('p-4 hover:shadow-md transition-shadow', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-semibold text-sm">
|
||||
{user.email[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium text-gray-900">{user.email}</span>
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border',
|
||||
getRoleColor(user.role)
|
||||
)}>
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs',
|
||||
getStatusColor(user.isActive)
|
||||
)}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<span>Created {formatDate(user.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showAdminActions && (
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Edit className="w-4 h-4" />,
|
||||
onClick: () => onEdit?.(user),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <div className="w-4 h-4 bg-red-500 rounded-full" />,
|
||||
onClick: () => onDelete?.(user),
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('p-6', className)}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-bold text-xl">
|
||||
{user.email[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{user.email}</h3>
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-sm font-medium border',
|
||||
getRoleColor(user.role)
|
||||
)}>
|
||||
<Shield className="w-4 h-4 mr-1" />
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-3">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Mail className="w-4 h-4" />
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>Created {formatDate(user.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={user.isActive}
|
||||
onCheckedChange={() => onToggleStatus?.(user)}
|
||||
disabled={!showAdminActions}
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-sm font-medium',
|
||||
user.isActive ? 'text-green-600' : 'text-gray-500'
|
||||
)}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-gray-500">
|
||||
Updated {formatDate(user.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showAdminActions && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit?.(user)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<Button variant="outline" size="sm">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <div className="w-4 h-4 bg-red-500 rounded-full" />,
|
||||
onClick: () => onDelete?.(user),
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { UserResponse, CreateUserDto, UpdateUserDto, Role } from '@goodgo/types';
|
||||
import { User, Mail, Shield, Save, X } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select } from '../ui/select';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: Props for UserForm component
|
||||
* VI: Props cho component UserForm
|
||||
*/
|
||||
export interface UserFormProps {
|
||||
/** EN: User to edit (null for create mode) / VI: User để edit (null cho create mode) */
|
||||
user?: UserResponse | null;
|
||||
/** EN: Whether this is create mode / VI: Có phải create mode không */
|
||||
isCreate?: boolean;
|
||||
/** EN: Loading state / VI: Trạng thái loading */
|
||||
loading?: boolean;
|
||||
/** EN: Callback when form is submitted / VI: Callback khi form được submit */
|
||||
onSubmit: (data: CreateUserDto | UpdateUserDto) => Promise<void>;
|
||||
/** EN: Callback when form is cancelled / VI: Callback khi form bị cancel */
|
||||
onCancel?: () => void;
|
||||
/** EN: Additional CSS classes / VI: CSS classes bổ sung */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: User Form Component - Create and edit user forms
|
||||
* VI: Component User Form - Forms tạo và edit user
|
||||
*
|
||||
* Features:
|
||||
* - Create new user form
|
||||
* - Edit existing user form
|
||||
* - Form validation
|
||||
* - Role selection
|
||||
* - Password fields for creation
|
||||
* - Active status toggle
|
||||
*/
|
||||
export function UserForm({
|
||||
user,
|
||||
isCreate = false,
|
||||
loading = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
className,
|
||||
}: UserFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: Role.USER,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Initialize form data when user prop changes
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
email: user.email,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
role: Role.USER,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error for this field
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
// Email validation
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
// Password validation (only for create mode)
|
||||
if (isCreate) {
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Password is required';
|
||||
} else if (formData.password.length < 8) {
|
||||
newErrors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwords do not match';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isCreate) {
|
||||
const createData: CreateUserDto = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
role: formData.role,
|
||||
};
|
||||
await onSubmit(createData);
|
||||
} else {
|
||||
const updateData: UpdateUserDto = {
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
isActive: formData.isActive,
|
||||
};
|
||||
await onSubmit(updateData);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error handling is done in the parent component
|
||||
}
|
||||
};
|
||||
|
||||
const roleOptions = [
|
||||
{ value: Role.USER, label: 'User' },
|
||||
{ value: Role.ADMIN, label: 'Admin' },
|
||||
{ value: Role.SUPER_ADMIN, label: 'Super Admin' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className={cn('p-6', className)}>
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{isCreate ? 'Create New User' : 'Edit User'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isCreate ? 'Add a new user to the system' : 'Update user information and permissions'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
className={cn(
|
||||
'pl-10',
|
||||
errors.email && 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
placeholder="user@example.com"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Fields (Create Mode Only) */}
|
||||
{isCreate && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
className={cn(
|
||||
errors.password && 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
placeholder="Enter password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirm Password
|
||||
</label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
className={cn(
|
||||
errors.confirmPassword && 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
)}
|
||||
placeholder="Confirm password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Role Selection */}
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Role
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Shield className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 z-10" />
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(value) => handleInputChange('role', value as Role)}
|
||||
disabled={loading}
|
||||
>
|
||||
{roleOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Status (Edit Mode Only) */}
|
||||
{!isCreate && (
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">Account Status</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{formData.isActive ? 'User can access the system' : 'User is deactivated'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => handleInputChange('isActive', checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-3 pt-6 border-t">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
{isCreate ? 'Creating...' : 'Saving...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isCreate ? 'Create User' : 'Save Changes'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { UserResponse, Role } from '@goodgo/types';
|
||||
import { MoreHorizontal, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { DropdownMenu } from '../ui/dropdown-menu';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
/**
|
||||
* EN: Props for UsersTable component
|
||||
* VI: Props cho component UsersTable
|
||||
*/
|
||||
export interface UsersTableProps {
|
||||
/** EN: Array of users to display / VI: Mảng users để hiển thị */
|
||||
users: UserResponse[];
|
||||
/** EN: Loading state / VI: Trạng thái loading */
|
||||
loading?: boolean;
|
||||
/** EN: Callback when user is selected for editing / VI: Callback khi user được chọn để edit */
|
||||
onEditUser?: (user: UserResponse) => void;
|
||||
/** EN: Callback when user deletion is requested / VI: Callback khi yêu cầu xóa user */
|
||||
onDeleteUser?: (user: UserResponse) => void;
|
||||
/** EN: Callback when user active status is toggled / VI: Callback khi toggle trạng thái active của user */
|
||||
onToggleUserStatus?: (user: UserResponse) => void;
|
||||
/** EN: Callback when bulk actions are performed / VI: Callback khi thực hiện bulk actions */
|
||||
onBulkAction?: (action: 'delete' | 'activate' | 'deactivate', userIds: string[]) => void;
|
||||
/** EN: Whether to show bulk actions / VI: Có hiển thị bulk actions không */
|
||||
showBulkActions?: boolean;
|
||||
/** EN: Additional CSS classes / VI: CSS classes bổ sung */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Users Table Component - Displays users in a data table with actions
|
||||
* VI: Component Users Table - Hiển thị users trong data table với actions
|
||||
*
|
||||
* Features:
|
||||
* - Sortable columns
|
||||
* - Bulk selection and actions
|
||||
* - Individual user actions (edit, delete, toggle status)
|
||||
* - Responsive design
|
||||
* - Loading states
|
||||
*/
|
||||
export function UsersTable({
|
||||
users,
|
||||
loading = false,
|
||||
onEditUser,
|
||||
onDeleteUser,
|
||||
onToggleUserStatus,
|
||||
onBulkAction,
|
||||
showBulkActions = true,
|
||||
className,
|
||||
}: UsersTableProps) {
|
||||
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
|
||||
const [sortField, setSortField] = useState<'email' | 'role' | 'createdAt' | 'isActive'>('createdAt');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Sort users based on current sort settings
|
||||
const sortedUsers = React.useMemo(() => {
|
||||
return [...users].sort((a, b) => {
|
||||
let aValue: any = a[sortField];
|
||||
let bValue: any = b[sortField];
|
||||
|
||||
if (sortField === 'createdAt' || sortField === 'updatedAt') {
|
||||
aValue = new Date(aValue).getTime();
|
||||
bValue = new Date(bValue).getTime();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [users, sortField, sortDirection]);
|
||||
|
||||
const handleSort = (field: typeof sortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedUsers(new Set(users.map(user => user.id)));
|
||||
} else {
|
||||
setSelectedUsers(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectUser = (userId: string, checked: boolean) => {
|
||||
const newSelected = new Set(selectedUsers);
|
||||
if (checked) {
|
||||
newSelected.add(userId);
|
||||
} else {
|
||||
newSelected.delete(userId);
|
||||
}
|
||||
setSelectedUsers(newSelected);
|
||||
};
|
||||
|
||||
const handleBulkAction = (action: 'delete' | 'activate' | 'deactivate') => {
|
||||
if (selectedUsers.size > 0) {
|
||||
onBulkAction?.(action, Array.from(selectedUsers));
|
||||
setSelectedUsers(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadgeColor = (role: Role) => {
|
||||
switch (role) {
|
||||
case Role.SUPER_ADMIN:
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case Role.ADMIN:
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case Role.USER:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={cn('p-6', className)}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-3 text-gray-600">Loading users...</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Bulk Actions Bar */}
|
||||
{showBulkActions && selectedUsers.size > 0 && (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-blue-800">
|
||||
{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('activate')}
|
||||
className="text-green-700 border-green-300 hover:bg-green-50"
|
||||
>
|
||||
<UserCheck className="w-4 h-4 mr-2" />
|
||||
Activate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('deactivate')}
|
||||
className="text-orange-700 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<UserX className="w-4 h-4 mr-2" />
|
||||
Deactivate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
className="text-red-700 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
{showBulkActions && (
|
||||
<th className="px-6 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUsers.size === users.length && users.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('email')}
|
||||
>
|
||||
Email {sortField === 'email' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('role')}
|
||||
>
|
||||
Role {sortField === 'role' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('isActive')}
|
||||
>
|
||||
Status {sortField === 'isActive' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('createdAt')}
|
||||
>
|
||||
Created {sortField === 'createdAt' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedUsers.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
{showBulkActions && (
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUsers.has(user.id)}
|
||||
onChange={(e) => handleSelectUser(user.id, e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{user.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||||
getRoleBadgeColor(user.role)
|
||||
)}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={user.isActive}
|
||||
onCheckedChange={() => onToggleUserStatus?.(user)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-sm',
|
||||
user.isActive ? 'text-green-600' : 'text-gray-400'
|
||||
)}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<DropdownMenu
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: <Edit className="w-4 h-4" />,
|
||||
onClick: () => onEditUser?.(user),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: <Trash2 className="w-4 h-4" />,
|
||||
onClick: () => onDeleteUser?.(user),
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{sortedUsers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No users found</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* EN: Users Components Exports
|
||||
* VI: Exports cho Users Components
|
||||
*/
|
||||
|
||||
export { UsersTable } from './UsersTable';
|
||||
export type { UsersTableProps } from './UsersTable';
|
||||
|
||||
export { UserCard } from './UserCard';
|
||||
export type { UserCardProps } from './UserCard';
|
||||
|
||||
export { UserForm } from './UserForm';
|
||||
export type { UserFormProps } from './UserForm';
|
||||
@@ -1,81 +0,0 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* EN: Custom translation hook
|
||||
* VI: Hook translation tùy chỉnh
|
||||
*/
|
||||
|
||||
import { useI18n } from '@/features/theme';
|
||||
import enMessages from '../i18n/en.json';
|
||||
import viMessages from '../i18n/vi.json';
|
||||
|
||||
/**
|
||||
* EN: Custom hook for translations with locale management
|
||||
* VI: Hook tùy chỉnh cho translations với quản lý locale
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const t = useTranslation();
|
||||
* const saveText = t('common.save');
|
||||
* const loginTitle = t('auth.login.title');
|
||||
* ```
|
||||
*/
|
||||
export function useTranslation() {
|
||||
const { locale, setLocale } = useI18n();
|
||||
|
||||
// EN: Get messages based on current locale
|
||||
// VI: Lấy messages dựa trên locale hiện tại
|
||||
const messages = locale === 'vi' ? viMessages : enMessages;
|
||||
|
||||
/**
|
||||
* EN: Translation function that supports nested keys and interpolation
|
||||
* VI: Hàm translation hỗ trợ nested keys và interpolation
|
||||
*/
|
||||
const t = (key: string, values?: Record<string, any>): 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,
|
||||
};
|
||||
}
|
||||
211
apps/web-client/src/features/shared/middleware/auth-guard.tsx
Normal file
211
apps/web-client/src/features/shared/middleware/auth-guard.tsx
Normal file
@@ -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
|
||||
* <AuthGuard>
|
||||
* <ProtectedContent />
|
||||
* </AuthGuard>
|
||||
*
|
||||
* // Require specific role
|
||||
* <AuthGuard requiredRoles={[Role.ADMIN]}>
|
||||
* <AdminContent />
|
||||
* </AuthGuard>
|
||||
*
|
||||
* // Require multiple roles
|
||||
* <AuthGuard requiredRoles={[Role.ADMIN, Role.SUPER_ADMIN]}>
|
||||
* <SuperAdminContent />
|
||||
* </AuthGuard>
|
||||
* ```
|
||||
*/
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="p-8 text-center max-w-md">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Access Denied</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You don't have permission to access this resource.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button onClick={() => router.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push('/dashboard')}>
|
||||
Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
requiredRoles?: Role[]
|
||||
) {
|
||||
return function AuthenticatedComponent(props: P) {
|
||||
return (
|
||||
<AuthGuard requiredRoles={requiredRoles}>
|
||||
<Component {...props} />
|
||||
</AuthGuard>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Role-based guard components
|
||||
* VI: Components guard dựa trên vai trò
|
||||
*/
|
||||
export const RequireAdmin: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<AuthGuard requiredRoles={[Role.ADMIN, Role.SUPER_ADMIN]}>
|
||||
{children}
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
export const RequireSuperAdmin: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<AuthGuard requiredRoles={[Role.SUPER_ADMIN]}>
|
||||
{children}
|
||||
</AuthGuard>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,7 +31,7 @@ interface I18nContextType {
|
||||
* EN: i18n Context
|
||||
* VI: Context i18n
|
||||
*/
|
||||
const I18nContext = React.createContext<I18nContextType | undefined>(undefined);
|
||||
export const I18nContext = React.createContext<I18nContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* EN: Get locale from localStorage or browser
|
||||
|
||||
@@ -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 (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
// 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 (
|
||||
<CustomI18nProvider>
|
||||
<NextIntlProviderWrapper>{children}</NextIntlProviderWrapper>
|
||||
</CustomI18nProvider>
|
||||
<I18nContext.Provider value={customContextValue}>
|
||||
<NextIntlClientProvider
|
||||
locale={locale}
|
||||
messages={messages}
|
||||
timeZone={defaultTimeZone}
|
||||
>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user