complete role and create user route
This commit is contained in:
parent
3776ce6e22
commit
df49bde2c4
249
SYSTEM_ADMIN_PRIORITY_GUIDE.md
Normal file
249
SYSTEM_ADMIN_PRIORITY_GUIDE.md
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
# System Admin Priority Logic - Hướng dẫn
|
||||||
|
|
||||||
|
## Tổng quan
|
||||||
|
|
||||||
|
Đã cập nhật logic để **System Admin** (Priority = 0) trở thành quyền cao nhất trong hệ thống.
|
||||||
|
|
||||||
|
### Quy tắc Priority
|
||||||
|
|
||||||
|
```
|
||||||
|
Priority càng thấp = Quyền càng cao
|
||||||
|
Priority = 0 (System Admin) = Quyền cao nhất
|
||||||
|
```
|
||||||
|
|
||||||
|
## Các thay đổi đã thực hiện
|
||||||
|
|
||||||
|
### 1. Constants mới (`src/config/constants.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const SYSTEM_ADMIN_PRIORITY = 0;
|
||||||
|
|
||||||
|
export const RolePriority = {
|
||||||
|
SYSTEM_ADMIN: 0,
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mục đích**: Định nghĩa giá trị priority của System Admin, tránh hardcode số 0 trong code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Helper Functions (`src/helpers/roleHelpers.ts`)
|
||||||
|
|
||||||
|
#### `isSystemAdminPriority(priority: number): boolean`
|
||||||
|
Kiểm tra xem priority có phải là System Admin không.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { isSystemAdminPriority } from '@/helpers/roleHelpers';
|
||||||
|
|
||||||
|
if (isSystemAdminPriority(userPriority)) {
|
||||||
|
// User là System Admin
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `hasHigherOrEqualPriority(priority1, priority2): boolean`
|
||||||
|
So sánh 2 priority (nhỏ hơn = cao hơn).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (hasHigherOrEqualPriority(userPriority, requiredPriority)) {
|
||||||
|
// User có đủ quyền
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `getPriorityLabel(priority: number): string`
|
||||||
|
Lấy nhãn mô tả cho priority.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
getPriorityLabel(0) // "System Admin (Highest)"
|
||||||
|
getPriorityLabel(5) // "Priority 5"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. useAuth Hook (`src/hooks/useAuth.tsx`)
|
||||||
|
|
||||||
|
Thêm method mới: `isSystemAdmin()`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { isSystemAdmin } = useAuth();
|
||||||
|
|
||||||
|
if (isSystemAdmin()) {
|
||||||
|
// User là System Admin (priority = 0)
|
||||||
|
console.log('You have highest permission!');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Interface cập nhật:**
|
||||||
|
```typescript
|
||||||
|
export interface IAuthContext {
|
||||||
|
// ... các field cũ
|
||||||
|
isSystemAdmin: () => boolean; // ← Mới
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Sidebar Logic (`src/components/sidebars/app-sidebar.tsx`)
|
||||||
|
|
||||||
|
Cập nhật logic kiểm tra admin:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TRƯỚC
|
||||||
|
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL);
|
||||||
|
|
||||||
|
// SAU
|
||||||
|
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL) || isSystemAdmin();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lợi ích:**
|
||||||
|
- System Admin (Priority = 0) thấy tất cả menu items
|
||||||
|
- Không cần phải có permission `ALLOW_ALL` trong database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cách sử dụng
|
||||||
|
|
||||||
|
### Kiểm tra System Admin trong component
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const { isSystemAdmin, role } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isSystemAdmin() && (
|
||||||
|
<AdminOnlyFeature />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p>Role: {role.roleName}</p>
|
||||||
|
<p>Priority: {role.priority}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kiểm tra priority trong logic nghiệp vụ
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { isSystemAdminPriority, hasHigherOrEqualPriority } from '@/helpers/roleHelpers';
|
||||||
|
|
||||||
|
function canDeleteUser(currentUserPriority: number, targetUserPriority: number): boolean {
|
||||||
|
// System Admin có thể xóa bất kỳ ai
|
||||||
|
if (isSystemAdminPriority(currentUserPriority)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User chỉ có thể xóa user có priority thấp hơn (số lớn hơn)
|
||||||
|
return hasHigherOrEqualPriority(currentUserPriority, targetUserPriority);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hiển thị label priority
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getPriorityLabel } from '@/helpers/roleHelpers';
|
||||||
|
|
||||||
|
<Badge>{getPriorityLabel(role.priority)}</Badge>
|
||||||
|
// System Admin sẽ hiển thị: "System Admin (Highest)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Luồng kiểm tra quyền
|
||||||
|
|
||||||
|
```
|
||||||
|
User đăng nhập
|
||||||
|
↓
|
||||||
|
Priority được lưu vào localStorage
|
||||||
|
↓
|
||||||
|
useAuth hook load priority
|
||||||
|
↓
|
||||||
|
isSystemAdmin() kiểm tra priority === 0
|
||||||
|
↓
|
||||||
|
Sidebar check: ALLOW_ALL || isSystemAdmin()
|
||||||
|
↓
|
||||||
|
Hiển thị menu items phù hợp
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ví dụ thực tế
|
||||||
|
|
||||||
|
### Ví dụ 1: Ẩn/hiện nút Delete dựa trên priority
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function UserManagement() {
|
||||||
|
const { role, isSystemAdmin } = useAuth();
|
||||||
|
const currentUserPriority = role.priority;
|
||||||
|
|
||||||
|
function canDelete(targetUserPriority: number): boolean {
|
||||||
|
// System Admin xóa được tất cả
|
||||||
|
if (isSystemAdmin()) return true;
|
||||||
|
|
||||||
|
// Priority thấp hơn (số nhỏ hơn) mới xóa được
|
||||||
|
return currentUserPriority < targetUserPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
{users.map(user => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>{user.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{canDelete(user.role.priority) && (
|
||||||
|
<DeleteButton userId={user.id} />
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ví dụ 2: Route protection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { redirect } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/admin-panel')({
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const { isSystemAdmin } = context.auth;
|
||||||
|
|
||||||
|
if (!isSystemAdmin()) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/unauthorized',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AdminPanel,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tóm tắt
|
||||||
|
|
||||||
|
✅ **Priority = 0** là System Admin (quyền cao nhất)
|
||||||
|
✅ **Priority thấp hơn** = Quyền cao hơn
|
||||||
|
✅ Có constants và helpers để tái sử dụng
|
||||||
|
✅ `isSystemAdmin()` method trong useAuth hook
|
||||||
|
✅ Sidebar tự động nhận biết System Admin
|
||||||
|
✅ Không cần hardcode giá trị priority nữa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files đã thay đổi
|
||||||
|
|
||||||
|
1. ✅ `src/config/constants.ts` - Constants mới
|
||||||
|
2. ✅ `src/helpers/roleHelpers.ts` - Helper functions
|
||||||
|
3. ✅ `src/hooks/useAuth.tsx` - Thêm isSystemAdmin()
|
||||||
|
4. ✅ `src/types/auth.ts` - Cập nhật interface
|
||||||
|
5. ✅ `src/components/sidebars/app-sidebar.tsx` - Logic admin check
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Lưu ý quan trọng:**
|
||||||
|
Backend cũng cần implement logic tương tự để đảm bảo consistency giữa frontend và backend!
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { LogOut, Settings, User } from "lucide-react";
|
import { LogOut, Settings, User, Key } from "lucide-react";
|
||||||
|
|
||||||
interface AvatarDropdownProps {
|
interface AvatarDropdownProps {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -18,6 +18,7 @@ interface AvatarDropdownProps {
|
||||||
onLogOut: () => void;
|
onLogOut: () => void;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
onProfile?: () => void;
|
onProfile?: () => void;
|
||||||
|
onChangePassword?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AvatarDropdown({
|
export default function AvatarDropdown({
|
||||||
|
|
@ -26,6 +27,7 @@ export default function AvatarDropdown({
|
||||||
onLogOut,
|
onLogOut,
|
||||||
onSettings,
|
onSettings,
|
||||||
onProfile,
|
onProfile,
|
||||||
|
onChangePassword,
|
||||||
}: AvatarDropdownProps) {
|
}: AvatarDropdownProps) {
|
||||||
// Get initials from username
|
// Get initials from username
|
||||||
const getInitials = (name: string): string => {
|
const getInitials = (name: string): string => {
|
||||||
|
|
@ -62,7 +64,13 @@ export default function AvatarDropdown({
|
||||||
{onProfile && (
|
{onProfile && (
|
||||||
<DropdownMenuItem onClick={onProfile} className="cursor-pointer">
|
<DropdownMenuItem onClick={onProfile} className="cursor-pointer">
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
<span>Tài khoản của tôi</span>
|
<span>Thông tin cá nhân</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{onChangePassword && (
|
||||||
|
<DropdownMenuItem onClick={onChangePassword} className="cursor-pointer">
|
||||||
|
<Key className="mr-2 h-4 w-4" />
|
||||||
|
<span>Đổi mật khẩu</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{onSettings && (
|
{onSettings && (
|
||||||
|
|
@ -71,7 +79,7 @@ export default function AvatarDropdown({
|
||||||
<span>Cài đặt</span>
|
<span>Cài đặt</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{(onProfile || onSettings) && <DropdownMenuSeparator />}
|
{(onProfile || onChangePassword || onSettings) && <DropdownMenuSeparator />}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={onLogOut}
|
onClick={onLogOut}
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useForm, formOptions } from "@tanstack/react-form";
|
import { useForm, formOptions } from "@tanstack/react-form";
|
||||||
import axios from "axios";
|
import axios from "@/config/axios";
|
||||||
|
|
||||||
interface AddBlacklistDialogProps {
|
interface AddBlacklistDialogProps {
|
||||||
onAdded?: () => void; // callback để refresh danh sách sau khi thêm
|
onAdded?: () => void; // callback để refresh danh sách sau khi thêm
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,8 @@ export function CustomPagination({
|
||||||
onPageSizeChange,
|
onPageSizeChange,
|
||||||
pageSizeOptions
|
pageSizeOptions
|
||||||
}: PaginationProps) {
|
}: PaginationProps) {
|
||||||
let startItem = (currentPage - 1) * itemsPerPage + 1;
|
const startItem = Math.max(1, (currentPage - 1) * itemsPerPage + 1);
|
||||||
let endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||||
if (currentPage === totalPages) {
|
|
||||||
startItem = totalItems - itemsPerPage + 1;
|
|
||||||
endItem = totalItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { appSidebarSection } from "@/types/app-sidebar";
|
import { appSidebarSection } from "@/types/app-sidebar";
|
||||||
import { PermissionEnum } from "@/types/permission";
|
import { PermissionEnum } from "@/types/permission";
|
||||||
|
|
@ -33,10 +39,10 @@ type SidebarSection = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const { hasPermission, acs } = useAuth();
|
const { hasPermission, acs, isSystemAdmin } = useAuth();
|
||||||
|
|
||||||
// Check if user is admin (has ALLOW_ALL permission)
|
// Check if user is admin (has ALLOW_ALL permission OR is System Admin with priority 0)
|
||||||
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL);
|
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL) || isSystemAdmin();
|
||||||
|
|
||||||
// Check if user has any of the required permissions
|
// Check if user has any of the required permissions
|
||||||
const checkPermissions = (permissions?: PermissionEnum[]) => {
|
const checkPermissions = (permissions?: PermissionEnum[]) => {
|
||||||
|
|
@ -44,7 +50,7 @@ export function AppSidebar() {
|
||||||
if (!permissions || permissions.length === 0) return true;
|
if (!permissions || permissions.length === 0) return true;
|
||||||
// Item marked as ALLOW_ALL = show to everyone
|
// Item marked as ALLOW_ALL = show to everyone
|
||||||
if (permissions.includes(PermissionEnum.ALLOW_ALL)) return true;
|
if (permissions.includes(PermissionEnum.ALLOW_ALL)) return true;
|
||||||
// Admin users see everything
|
// Admin users OR System Admin (priority=0) see everything
|
||||||
if (isAdmin) return true;
|
if (isAdmin) return true;
|
||||||
// Check if user has any of the required permissions
|
// Check if user has any of the required permissions
|
||||||
return permissions.some((permission) => hasPermission(permission));
|
return permissions.some((permission) => hasPermission(permission));
|
||||||
|
|
@ -62,10 +68,11 @@ export function AppSidebar() {
|
||||||
}, [acs]);
|
}, [acs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar
|
<TooltipProvider delayDuration={300}>
|
||||||
collapsible="icon"
|
<Sidebar
|
||||||
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
collapsible="icon"
|
||||||
>
|
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
|
>
|
||||||
<SidebarHeader className="border-b border-border/40 p-6">
|
<SidebarHeader className="border-b border-border/40 p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-gray-900 to-black text-white shadow-lg ring-1 ring-gray-800/30">
|
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-gray-900 to-black text-white shadow-lg ring-1 ring-gray-800/30">
|
||||||
|
|
@ -93,29 +100,36 @@ export function AppSidebar() {
|
||||||
<SidebarMenu className="space-y-1">
|
<SidebarMenu className="space-y-1">
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<Tooltip>
|
||||||
asChild
|
<TooltipTrigger asChild>
|
||||||
tooltip={item.title}
|
<SidebarMenuButton
|
||||||
className={cn(
|
asChild
|
||||||
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
|
tooltip={item.title}
|
||||||
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
|
className={cn(
|
||||||
"transition-all duration-200 ease-in-out",
|
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
|
||||||
"group relative overflow-hidden",
|
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
|
||||||
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
|
"transition-all duration-200 ease-in-out",
|
||||||
"data-[active=true]:shadow-md data-[active=true]:ring-1 data-[active=true]:ring-primary/20"
|
"group relative overflow-hidden",
|
||||||
)}
|
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
|
||||||
>
|
"data-[active=true]:shadow-md data-[active=true]:ring-1 data-[active=true]:ring-primary/20"
|
||||||
<Link
|
)}
|
||||||
href={item.url}
|
>
|
||||||
to={item.url}
|
<Link
|
||||||
className="flex items-center gap-3 w-full"
|
href={item.url}
|
||||||
>
|
to={item.url}
|
||||||
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
|
className="flex items-center gap-3 w-full"
|
||||||
<span className="font-medium text-sm truncate">
|
>
|
||||||
{item.title}
|
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
|
||||||
</span>
|
<span className="font-medium text-sm truncate">
|
||||||
</Link>
|
{item.title}
|
||||||
</SidebarMenuButton>
|
</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="font-medium">
|
||||||
|
{item.title}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|
@ -130,5 +144,6 @@ export function AppSidebar() {
|
||||||
</div>
|
</div>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,47 +61,21 @@ export const API_ENDPOINTS = {
|
||||||
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
|
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
|
||||||
},
|
},
|
||||||
PERMISSION: {
|
PERMISSION: {
|
||||||
// Lấy danh sách permission từ enum
|
|
||||||
GET_LIST: `${BASE_URL}/Permission/list`,
|
GET_LIST: `${BASE_URL}/Permission/list`,
|
||||||
|
|
||||||
// Lấy permission theo category
|
|
||||||
GET_BY_CATEGORY: `${BASE_URL}/Permission/list-by-category`,
|
GET_BY_CATEGORY: `${BASE_URL}/Permission/list-by-category`,
|
||||||
|
|
||||||
// Lấy chi tiết permission theo value
|
|
||||||
GET_BY_VALUE: (value: number) => `${BASE_URL}/Permission/${value}`,
|
GET_BY_VALUE: (value: number) => `${BASE_URL}/Permission/${value}`,
|
||||||
|
|
||||||
// Import permission từ enum vào DB (chạy 1 lần đầu tiên)
|
|
||||||
SEED_FROM_ENUM: `${BASE_URL}/Permission/seed-from-enum`,
|
SEED_FROM_ENUM: `${BASE_URL}/Permission/seed-from-enum`,
|
||||||
|
|
||||||
// Lấy danh sách permission từ database
|
|
||||||
GET_DB_LIST: `${BASE_URL}/Permission/db-list`,
|
GET_DB_LIST: `${BASE_URL}/Permission/db-list`,
|
||||||
|
|
||||||
// Xóa permission
|
|
||||||
DELETE: (id: number) => `${BASE_URL}/Permission/${id}`,
|
DELETE: (id: number) => `${BASE_URL}/Permission/${id}`,
|
||||||
},
|
},
|
||||||
ROLE: {
|
ROLE: {
|
||||||
// Lấy danh sách tất cả roles
|
|
||||||
GET_LIST: `${BASE_URL}/Role/list`,
|
GET_LIST: `${BASE_URL}/Role/list`,
|
||||||
|
|
||||||
// Lấy chi tiết role theo ID
|
|
||||||
GET_BY_ID: (id: number) => `${BASE_URL}/Role/${id}`,
|
GET_BY_ID: (id: number) => `${BASE_URL}/Role/${id}`,
|
||||||
|
|
||||||
// Tạo role mới
|
|
||||||
CREATE: `${BASE_URL}/Role/create`,
|
CREATE: `${BASE_URL}/Role/create`,
|
||||||
|
|
||||||
// Cập nhật role
|
|
||||||
UPDATE: (id: number) => `${BASE_URL}/Role/update/${id}`,
|
UPDATE: (id: number) => `${BASE_URL}/Role/update/${id}`,
|
||||||
|
|
||||||
// Xóa role
|
|
||||||
DELETE: (id: number) => `${BASE_URL}/Role/${id}`,
|
DELETE: (id: number) => `${BASE_URL}/Role/${id}`,
|
||||||
|
|
||||||
// Lấy danh sách permissions của role
|
|
||||||
GET_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/permissions`,
|
GET_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/permissions`,
|
||||||
|
|
||||||
// Gán permissions cho role (thay thế toàn bộ)
|
|
||||||
ASSIGN_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/assign-permissions`,
|
ASSIGN_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/assign-permissions`,
|
||||||
|
|
||||||
// Bật/tắt một permission cụ thể (query param: ?isChecked=true/false)
|
|
||||||
TOGGLE_PERMISSION: (roleId: number, permissionId: number) =>
|
TOGGLE_PERMISSION: (roleId: number, permissionId: number) =>
|
||||||
`${BASE_URL}/Role/${roleId}/permissions/${permissionId}/toggle`,
|
`${BASE_URL}/Role/${roleId}/permissions/${permissionId}/toggle`,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
49
src/config/axios.ts
Normal file
49
src/config/axios.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
// Re-export types from axios for convenience
|
||||||
|
export type { AxiosProgressEvent, AxiosRequestConfig, AxiosResponse, AxiosError } from "axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axios instance với interceptor tự động gửi token
|
||||||
|
*/
|
||||||
|
const axiosInstance = axios.create();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request interceptor - Tự động thêm Authorization header
|
||||||
|
*/
|
||||||
|
axiosInstance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// Lấy token từ localStorage
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
|
// Nếu có token, thêm vào header Authorization
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response interceptor - Xử lý lỗi 401 (Unauthorized)
|
||||||
|
*/
|
||||||
|
axiosInstance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
// Nếu nhận được 401, có thể redirect về trang login
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Có thể thêm logic redirect hoặc refresh token ở đây
|
||||||
|
console.warn("Unauthorized - Token may be expired or invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default axiosInstance;
|
||||||
10
src/config/constants.ts
Normal file
10
src/config/constants.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* System-wide constants
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System Admin priority value
|
||||||
|
* Priority = 0 means highest permission level (System Admin)
|
||||||
|
* Lower priority number = Higher permission level
|
||||||
|
*/
|
||||||
|
export const SYSTEM_ADMIN_PRIORITY = 0;
|
||||||
45
src/helpers/roleHelpers.ts
Normal file
45
src/helpers/roleHelpers.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { SYSTEM_ADMIN_PRIORITY } from "@/config/constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a priority value indicates System Admin
|
||||||
|
* @param priority - The priority value to check
|
||||||
|
* @returns true if the priority is System Admin (0), false otherwise
|
||||||
|
*/
|
||||||
|
export function isSystemAdminPriority(priority: number): boolean {
|
||||||
|
return priority === SYSTEM_ADMIN_PRIORITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a priority has higher permission than another
|
||||||
|
* Lower number = Higher permission (System Admin = 0 is highest)
|
||||||
|
* @param priority1 - First priority to compare
|
||||||
|
* @param priority2 - Second priority to compare
|
||||||
|
* @returns true if priority1 has higher or equal permission than priority2
|
||||||
|
*/
|
||||||
|
export function hasHigherOrEqualPriority(priority1: number, priority2: number): boolean {
|
||||||
|
return priority1 <= priority2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two priorities
|
||||||
|
* @param priority1 - First priority to compare
|
||||||
|
* @param priority2 - Second priority to compare
|
||||||
|
* @returns -1 if priority1 > priority2, 0 if equal, 1 if priority1 < priority2
|
||||||
|
*/
|
||||||
|
export function comparePriorities(priority1: number, priority2: number): -1 | 0 | 1 {
|
||||||
|
if (priority1 < priority2) return 1; // Lower number = higher permission
|
||||||
|
if (priority1 > priority2) return -1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable priority label
|
||||||
|
* @param priority - The priority value
|
||||||
|
* @returns A label for the priority
|
||||||
|
*/
|
||||||
|
export function getPriorityLabel(priority: number): string {
|
||||||
|
if (priority === SYSTEM_ADMIN_PRIORITY) {
|
||||||
|
return "System Admin (Highest)";
|
||||||
|
}
|
||||||
|
return `Priority ${priority}`;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as authService from "@/services/auth.service";
|
import * as authService from "@/services/auth.service";
|
||||||
|
import type { CreateAccountRequest } from "@/types/auth";
|
||||||
import type { LoginResquest, LoginResponse } from "@/types/auth";
|
import type { LoginResquest, LoginResponse } from "@/types/auth";
|
||||||
|
|
||||||
const AUTH_QUERY_KEYS = {
|
const AUTH_QUERY_KEYS = {
|
||||||
|
|
@ -105,13 +106,7 @@ export function useCreateAccount() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: {
|
mutationFn: (data: CreateAccountRequest) => authService.createAccount(data),
|
||||||
userName: string;
|
|
||||||
password: string;
|
|
||||||
name: string;
|
|
||||||
roleId: number;
|
|
||||||
accessBuildings?: number[];
|
|
||||||
}) => authService.createAccount(data),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Có thể invalidate user list query nếu có
|
// Có thể invalidate user list query nếu có
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { sleep } from "@/lib/utils";
|
import { sleep } from "@/lib/utils";
|
||||||
import { PermissionEnum } from "@/types/permission";
|
import { PermissionEnum } from "@/types/permission";
|
||||||
|
import { SYSTEM_ADMIN_PRIORITY } from "@/config/constants";
|
||||||
import React, { useContext, useEffect, useMemo } from "react";
|
import React, { useContext, useEffect, useMemo } from "react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
|
@ -13,6 +14,7 @@ export interface IAuthContext {
|
||||||
name: string;
|
name: string;
|
||||||
acs: number[];
|
acs: number[];
|
||||||
hasPermission: (permission: PermissionEnum) => boolean;
|
hasPermission: (permission: PermissionEnum) => boolean;
|
||||||
|
isSystemAdmin: () => boolean;
|
||||||
role: {
|
role: {
|
||||||
roleName: string;
|
roleName: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
|
@ -61,6 +63,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
[acs]
|
[acs]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isSystemAdmin = useCallback(() => {
|
||||||
|
return Number(priority) === SYSTEM_ADMIN_PRIORITY;
|
||||||
|
}, [priority]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await sleep(250);
|
await sleep(250);
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
|
|
@ -89,7 +95,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
name,
|
name,
|
||||||
acs,
|
acs,
|
||||||
role: { roleName, priority: Number(priority) },
|
role: { roleName, priority: Number(priority) },
|
||||||
hasPermission
|
hasPermission,
|
||||||
|
isSystemAdmin
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,14 @@ import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklis
|
||||||
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
|
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
|
||||||
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
|
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
|
||||||
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
|
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
|
||||||
|
import { Route as AuthUserRoleIndexRouteImport } from './routes/_auth/user/role/index'
|
||||||
|
import { Route as AuthUserCreateIndexRouteImport } from './routes/_auth/user/create/index'
|
||||||
import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms/$roomName/index'
|
import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms/$roomName/index'
|
||||||
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
||||||
|
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
|
||||||
|
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
|
||||||
|
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
|
||||||
|
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
|
||||||
import { Route as AuthRoleIdEditIndexRouteImport } from './routes/_auth/role/$id/edit/index'
|
import { Route as AuthRoleIdEditIndexRouteImport } from './routes/_auth/role/$id/edit/index'
|
||||||
|
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
|
|
@ -78,6 +84,16 @@ const authLoginIndexRoute = authLoginIndexRouteImport.update({
|
||||||
path: '/login/',
|
path: '/login/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthUserRoleIndexRoute = AuthUserRoleIndexRouteImport.update({
|
||||||
|
id: '/user/role/',
|
||||||
|
path: '/user/role/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthUserCreateIndexRoute = AuthUserCreateIndexRouteImport.update({
|
||||||
|
id: '/user/create/',
|
||||||
|
path: '/user/create/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AuthRoomsRoomNameIndexRoute = AuthRoomsRoomNameIndexRouteImport.update({
|
const AuthRoomsRoomNameIndexRoute = AuthRoomsRoomNameIndexRouteImport.update({
|
||||||
id: '/rooms/$roomName/',
|
id: '/rooms/$roomName/',
|
||||||
path: '/rooms/$roomName/',
|
path: '/rooms/$roomName/',
|
||||||
|
|
@ -88,6 +104,29 @@ const AuthRoleCreateIndexRoute = AuthRoleCreateIndexRouteImport.update({
|
||||||
path: '/role/create/',
|
path: '/role/create/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthProfileChangePasswordIndexRoute =
|
||||||
|
AuthProfileChangePasswordIndexRouteImport.update({
|
||||||
|
id: '/profile/change-password/',
|
||||||
|
path: '/profile/change-password/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthProfileUserNameIndexRoute =
|
||||||
|
AuthProfileUserNameIndexRouteImport.update({
|
||||||
|
id: '/profile/$userName/',
|
||||||
|
path: '/profile/$userName/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
|
||||||
|
id: '/user/role/$roleId/',
|
||||||
|
path: '/user/role/$roleId/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthUserChangePasswordUserNameIndexRoute =
|
||||||
|
AuthUserChangePasswordUserNameIndexRouteImport.update({
|
||||||
|
id: '/user/change-password/$userName/',
|
||||||
|
path: '/user/change-password/$userName/',
|
||||||
|
getParentRoute: () => AuthRoute,
|
||||||
|
} as any)
|
||||||
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
|
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
|
||||||
id: '/role/$id/edit/',
|
id: '/role/$id/edit/',
|
||||||
path: '/role/$id/edit/',
|
path: '/role/$id/edit/',
|
||||||
|
|
@ -105,9 +144,15 @@ export interface FileRoutesByFullPath {
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/device': typeof AuthDeviceIndexRoute
|
||||||
'/role': typeof AuthRoleIndexRoute
|
'/role': typeof AuthRoleIndexRoute
|
||||||
'/rooms': typeof AuthRoomsIndexRoute
|
'/rooms': typeof AuthRoomsIndexRoute
|
||||||
|
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||||
|
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||||
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
|
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
|
||||||
|
'/user/create': typeof AuthUserCreateIndexRoute
|
||||||
|
'/user/role': typeof AuthUserRoleIndexRoute
|
||||||
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
||||||
|
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
|
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
|
@ -120,9 +165,15 @@ export interface FileRoutesByTo {
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/device': typeof AuthDeviceIndexRoute
|
||||||
'/role': typeof AuthRoleIndexRoute
|
'/role': typeof AuthRoleIndexRoute
|
||||||
'/rooms': typeof AuthRoomsIndexRoute
|
'/rooms': typeof AuthRoomsIndexRoute
|
||||||
|
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||||
|
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||||
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
|
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
|
||||||
|
'/user/create': typeof AuthUserCreateIndexRoute
|
||||||
|
'/user/role': typeof AuthUserRoleIndexRoute
|
||||||
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
||||||
|
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
|
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
|
|
@ -137,9 +188,15 @@ export interface FileRoutesById {
|
||||||
'/_auth/device/': typeof AuthDeviceIndexRoute
|
'/_auth/device/': typeof AuthDeviceIndexRoute
|
||||||
'/_auth/role/': typeof AuthRoleIndexRoute
|
'/_auth/role/': typeof AuthRoleIndexRoute
|
||||||
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
||||||
|
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
||||||
|
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
||||||
'/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute
|
'/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute
|
||||||
|
'/_auth/user/create/': typeof AuthUserCreateIndexRoute
|
||||||
|
'/_auth/user/role/': typeof AuthUserRoleIndexRoute
|
||||||
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
|
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
|
||||||
|
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
|
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
|
@ -154,9 +211,15 @@ export interface FileRouteTypes {
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/role'
|
| '/role'
|
||||||
| '/rooms'
|
| '/rooms'
|
||||||
|
| '/profile/$userName'
|
||||||
|
| '/profile/change-password'
|
||||||
| '/role/create'
|
| '/role/create'
|
||||||
| '/rooms/$roomName'
|
| '/rooms/$roomName'
|
||||||
|
| '/user/create'
|
||||||
|
| '/user/role'
|
||||||
| '/role/$id/edit'
|
| '/role/$id/edit'
|
||||||
|
| '/user/change-password/$userName'
|
||||||
|
| '/user/role/$roleId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -169,9 +232,15 @@ export interface FileRouteTypes {
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/role'
|
| '/role'
|
||||||
| '/rooms'
|
| '/rooms'
|
||||||
|
| '/profile/$userName'
|
||||||
|
| '/profile/change-password'
|
||||||
| '/role/create'
|
| '/role/create'
|
||||||
| '/rooms/$roomName'
|
| '/rooms/$roomName'
|
||||||
|
| '/user/create'
|
||||||
|
| '/user/role'
|
||||||
| '/role/$id/edit'
|
| '/role/$id/edit'
|
||||||
|
| '/user/change-password/$userName'
|
||||||
|
| '/user/role/$roleId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -185,9 +254,15 @@ export interface FileRouteTypes {
|
||||||
| '/_auth/device/'
|
| '/_auth/device/'
|
||||||
| '/_auth/role/'
|
| '/_auth/role/'
|
||||||
| '/_auth/rooms/'
|
| '/_auth/rooms/'
|
||||||
|
| '/_auth/profile/$userName/'
|
||||||
|
| '/_auth/profile/change-password/'
|
||||||
| '/_auth/role/create/'
|
| '/_auth/role/create/'
|
||||||
| '/_auth/rooms/$roomName/'
|
| '/_auth/rooms/$roomName/'
|
||||||
|
| '/_auth/user/create/'
|
||||||
|
| '/_auth/user/role/'
|
||||||
| '/_auth/role/$id/edit/'
|
| '/_auth/role/$id/edit/'
|
||||||
|
| '/_auth/user/change-password/$userName/'
|
||||||
|
| '/_auth/user/role/$roleId/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
|
|
@ -275,6 +350,20 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof authLoginIndexRouteImport
|
preLoaderRoute: typeof authLoginIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_auth/user/role/': {
|
||||||
|
id: '/_auth/user/role/'
|
||||||
|
path: '/user/role'
|
||||||
|
fullPath: '/user/role'
|
||||||
|
preLoaderRoute: typeof AuthUserRoleIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
|
'/_auth/user/create/': {
|
||||||
|
id: '/_auth/user/create/'
|
||||||
|
path: '/user/create'
|
||||||
|
fullPath: '/user/create'
|
||||||
|
preLoaderRoute: typeof AuthUserCreateIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_auth/rooms/$roomName/': {
|
'/_auth/rooms/$roomName/': {
|
||||||
id: '/_auth/rooms/$roomName/'
|
id: '/_auth/rooms/$roomName/'
|
||||||
path: '/rooms/$roomName'
|
path: '/rooms/$roomName'
|
||||||
|
|
@ -289,6 +378,34 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AuthRoleCreateIndexRouteImport
|
preLoaderRoute: typeof AuthRoleCreateIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/profile/change-password/': {
|
||||||
|
id: '/_auth/profile/change-password/'
|
||||||
|
path: '/profile/change-password'
|
||||||
|
fullPath: '/profile/change-password'
|
||||||
|
preLoaderRoute: typeof AuthProfileChangePasswordIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
|
'/_auth/profile/$userName/': {
|
||||||
|
id: '/_auth/profile/$userName/'
|
||||||
|
path: '/profile/$userName'
|
||||||
|
fullPath: '/profile/$userName'
|
||||||
|
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
|
'/_auth/user/role/$roleId/': {
|
||||||
|
id: '/_auth/user/role/$roleId/'
|
||||||
|
path: '/user/role/$roleId'
|
||||||
|
fullPath: '/user/role/$roleId'
|
||||||
|
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
|
'/_auth/user/change-password/$userName/': {
|
||||||
|
id: '/_auth/user/change-password/$userName/'
|
||||||
|
path: '/user/change-password/$userName'
|
||||||
|
fullPath: '/user/change-password/$userName'
|
||||||
|
preLoaderRoute: typeof AuthUserChangePasswordUserNameIndexRouteImport
|
||||||
|
parentRoute: typeof AuthRoute
|
||||||
|
}
|
||||||
'/_auth/role/$id/edit/': {
|
'/_auth/role/$id/edit/': {
|
||||||
id: '/_auth/role/$id/edit/'
|
id: '/_auth/role/$id/edit/'
|
||||||
path: '/role/$id/edit'
|
path: '/role/$id/edit'
|
||||||
|
|
@ -308,9 +425,15 @@ interface AuthRouteChildren {
|
||||||
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
|
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
|
||||||
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
|
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
|
||||||
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
|
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
|
||||||
|
AuthProfileUserNameIndexRoute: typeof AuthProfileUserNameIndexRoute
|
||||||
|
AuthProfileChangePasswordIndexRoute: typeof AuthProfileChangePasswordIndexRoute
|
||||||
AuthRoleCreateIndexRoute: typeof AuthRoleCreateIndexRoute
|
AuthRoleCreateIndexRoute: typeof AuthRoleCreateIndexRoute
|
||||||
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
|
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
|
||||||
|
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
|
||||||
|
AuthUserRoleIndexRoute: typeof AuthUserRoleIndexRoute
|
||||||
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
|
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
|
||||||
|
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
|
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteChildren: AuthRouteChildren = {
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
|
|
@ -322,9 +445,16 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||||
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
|
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
|
||||||
AuthRoleIndexRoute: AuthRoleIndexRoute,
|
AuthRoleIndexRoute: AuthRoleIndexRoute,
|
||||||
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
|
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
|
||||||
|
AuthProfileUserNameIndexRoute: AuthProfileUserNameIndexRoute,
|
||||||
|
AuthProfileChangePasswordIndexRoute: AuthProfileChangePasswordIndexRoute,
|
||||||
AuthRoleCreateIndexRoute: AuthRoleCreateIndexRoute,
|
AuthRoleCreateIndexRoute: AuthRoleCreateIndexRoute,
|
||||||
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
|
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
|
||||||
|
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
|
||||||
|
AuthUserRoleIndexRoute: AuthUserRoleIndexRoute,
|
||||||
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
|
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
|
||||||
|
AuthUserChangePasswordUserNameIndexRoute:
|
||||||
|
AuthUserChangePasswordUserNameIndexRoute,
|
||||||
|
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,14 @@ function RouteComponent() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProfile = () => {
|
||||||
|
navigate({ to: "/profile/$userName", params: { userName: auth.username } } as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = () => {
|
||||||
|
navigate({ to: "/profile/change-password" } as any);
|
||||||
|
};
|
||||||
|
|
||||||
const username = auth.username;
|
const username = auth.username;
|
||||||
|
|
||||||
if (!auth.isAuthenticated) {
|
if (!auth.isAuthenticated) {
|
||||||
|
|
@ -61,7 +69,13 @@ function RouteComponent() {
|
||||||
<AppBreadCrumb />
|
<AppBreadCrumb />
|
||||||
|
|
||||||
<div className="flex items-center gap-4 ml-auto">
|
<div className="flex items-center gap-4 ml-auto">
|
||||||
<AvatarDropdown username={username} role={auth.role} onLogOut={handleLogout} />
|
<AvatarDropdown
|
||||||
|
username={username}
|
||||||
|
role={auth.role}
|
||||||
|
onLogOut={handleLogout}
|
||||||
|
onProfile={handleProfile}
|
||||||
|
onChangePassword={handleChangePassword}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 overflow-x-auto">
|
<div className="flex flex-1 flex-col gap-4 p-4 overflow-x-auto">
|
||||||
|
|
|
||||||
89
src/routes/_auth/profile/$userName/index.tsx
Normal file
89
src/routes/_auth/profile/$userName/index.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { User, Key, Shield } from "lucide-react";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/profile/$userName/")({
|
||||||
|
component: UserProfileComponent,
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
const { userName } = params as unknown as { userName: string };
|
||||||
|
context.breadcrumbs = [
|
||||||
|
{ title: "Tài khoản", path: "#" },
|
||||||
|
{ title: "Thông tin cá nhân", path: `/profile/${userName}` },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function UserProfileComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const auth = useAuth();
|
||||||
|
const { userName } = Route.useParams() as { userName: string };
|
||||||
|
|
||||||
|
// Only allow viewing own profile
|
||||||
|
const isOwnProfile = auth.username === userName;
|
||||||
|
|
||||||
|
if (!isOwnProfile) {
|
||||||
|
return (
|
||||||
|
<div className="container w-2/3 mx-auto">
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||||
|
<p className="text-muted-foreground">Bạn không có quyền xem hồ sơ này</p>
|
||||||
|
<Button variant="outline" onClick={() => navigate({ to: "/dashboard" })}>
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container w-2/3 mx-auto">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Avatar Section */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center border">
|
||||||
|
<User className="h-12 w-12 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<div className="space-y-0">
|
||||||
|
<div className="flex justify-between items-center py-4 border-b">
|
||||||
|
<span className="text-muted-foreground">Tên đăng nhập</span>
|
||||||
|
<span className="font-medium">{auth.username}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center py-4 border-b">
|
||||||
|
<span className="text-muted-foreground">Họ và tên</span>
|
||||||
|
<span className="font-medium">{auth.name || "Chưa cập nhật"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center py-4 border-b">
|
||||||
|
<span className="text-muted-foreground">Vai trò</span>
|
||||||
|
<Badge variant="outline" className="flex items-center gap-1">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
{auth.role.roleName || "Chưa cập nhật"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center py-4 border-b">
|
||||||
|
<span className="text-muted-foreground">Cấp độ ưu tiên</span>
|
||||||
|
<span className="font-medium">{auth.role.priority}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Button */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => navigate({ to: "/profile/change-password" as any })}
|
||||||
|
>
|
||||||
|
<Key className="h-4 w-4 mr-2" />
|
||||||
|
Đổi mật khẩu
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
src/routes/_auth/profile/change-password/index.tsx
Normal file
157
src/routes/_auth/profile/change-password/index.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useChangePassword } from "@/hooks/queries";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/profile/change-password/")({
|
||||||
|
component: SelfChangePasswordComponent,
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
context.breadcrumbs = [
|
||||||
|
{ title: "Tài khoản", path: "#" },
|
||||||
|
{ title: "Đổi mật khẩu", path: "/profile/change-password" },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function SelfChangePasswordComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const mutation = useChangePassword();
|
||||||
|
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
if (!currentPassword) {
|
||||||
|
setError("Mật khẩu hiện tại là bắt buộc");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!newPassword) {
|
||||||
|
setError("Mật khẩu mới là bắt buộc");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError("Mật khẩu phải có ít nhất 6 ký tự");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!confirmPassword) {
|
||||||
|
setError("Xác nhận mật khẩu là bắt buộc");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError("Mật khẩu mới và xác nhận mật khẩu chưa giống nhau");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
setError("Mật khẩu mới không được trùng với mật khẩu hiện tại");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
mutation.mutate(
|
||||||
|
{ currentPassword: currentPassword, newPassword: newPassword },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Đổi mật khẩu thành công");
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
mutation.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Đổi mật khẩu thất bại, có lỗi xảy ra vui lòng thử lại");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigate({ to: "/dashboard" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container w-2/3 mx-auto">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="currentPassword">Mật khẩu hiện tại</Label>
|
||||||
|
<Input
|
||||||
|
id="currentPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Nhập mật khẩu hiện tại"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newPassword">Mật khẩu mới</Label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Nhập mật khẩu mới"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Xác nhận mật khẩu mới</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Nhập lại mật khẩu mới"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Lỗi</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mutation.isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Lỗi</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Có lỗi xảy ra, vui lòng thử lại
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Đang lưu....
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Cập nhật"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/routes/_auth/user/change-password/$userName/index.tsx
Normal file
140
src/routes/_auth/user/change-password/$userName/index.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useChangePasswordAdmin } from "@/hooks/queries";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { LoaderCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/user/change-password/$userName/")({
|
||||||
|
component: AdminChangePasswordComponent,
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
context.breadcrumbs = [
|
||||||
|
{ title: "Quản lý người dùng", path: "/user" },
|
||||||
|
{
|
||||||
|
title: `Thay đổi mật khẩu của người dùng ${params.userName}`,
|
||||||
|
path: `/user/change-password/${params.userName}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function AdminChangePasswordComponent() {
|
||||||
|
const { userName } = Route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const mutation = useChangePasswordAdmin();
|
||||||
|
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
if (!newPassword) {
|
||||||
|
setError("Mật khẩu mới là bắt buộc");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
setError("Mật khẩu phải có ít nhất 6 ký tự");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!confirmPassword) {
|
||||||
|
setError("Xác nhận mật khẩu là bắt buộc");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError("Mật khẩu mới và xác nhận mật khẩu chưa giống nhau");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
mutation.mutate(
|
||||||
|
{ username: userName, newPassword: newPassword },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Cập nhật mật khẩu thành công");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
mutation.reset();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Cập nhật mật khẩu thất bại, có lỗi xảy ra vui lòng thử lại");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigate({ to: "/dashboard" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container w-2/3 mx-auto">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="newPassword">Mật khẩu mới</Label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Nhập mật khẩu mới"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Xác nhận mật khẩu mới</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Nhập lại mật khẩu mới"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Lỗi</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mutation.isError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Lỗi</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Có lỗi xảy ra, vui lòng thử lại
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Đang lưu....
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Cập nhật"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
src/routes/_auth/user/create/index.tsx
Normal file
312
src/routes/_auth/user/create/index.tsx
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useGetRoleList, useCreateAccount } from "@/hooks/queries";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { UserPlus, ArrowLeft, Save, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/user/create/")({
|
||||||
|
component: CreateUserComponent,
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
context.breadcrumbs = [
|
||||||
|
{ title: "Quản lý người dùng", path: "#" },
|
||||||
|
{ title: "Tạo người dùng mới", path: "/user/create" },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function CreateUserComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: roles = [], isLoading: rolesLoading } = useGetRoleList();
|
||||||
|
const createMutation = useCreateAccount();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
userName: "",
|
||||||
|
name: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
roleId: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Validate username theo regex backend
|
||||||
|
const validateUserName = (userName: string): boolean => {
|
||||||
|
const regex = /^[a-zA-Z0-9_.]{3,20}$/;
|
||||||
|
return regex.test(userName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Validate username
|
||||||
|
if (!formData.userName) {
|
||||||
|
newErrors.userName = "Tên đăng nhập không được để trống";
|
||||||
|
} else if (!validateUserName(formData.userName)) {
|
||||||
|
newErrors.userName = "Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
newErrors.name = "Họ và tên không được để trống";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password
|
||||||
|
if (!formData.password) {
|
||||||
|
newErrors.password = "Mật khẩu không được để trống";
|
||||||
|
} else if (formData.password.length < 6) {
|
||||||
|
newErrors.password = "Mật khẩu phải có ít nhất 6 ký tự";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate confirm password
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
newErrors.confirmPassword = "Mật khẩu xác nhận không khớp";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate roleId
|
||||||
|
if (!formData.roleId) {
|
||||||
|
newErrors.roleId = "Vui lòng chọn vai trò";
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync({
|
||||||
|
userName: formData.userName,
|
||||||
|
name: formData.name,
|
||||||
|
password: formData.password,
|
||||||
|
roleId: Number(formData.roleId),
|
||||||
|
accessRooms: [0], // Default value, will be updated when Room API provides IDs
|
||||||
|
});
|
||||||
|
toast.success("Tạo tài khoản thành công!");
|
||||||
|
navigate({ to: "/dashboard" }); // TODO: Navigate to user list page when it exists
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage = error.response?.data?.message || "Tạo tài khoản thất bại!";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[field];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 py-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Tạo người dùng mới</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Thêm tài khoản người dùng mới vào hệ thống
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate({ to: "/dashboard" })}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="w-full">
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
Thông tin tài khoản
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Điền thông tin để tạo tài khoản người dùng mới
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6 pt-6">
|
||||||
|
{/* Username and Name - Grid Layout */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="userName">
|
||||||
|
Tên đăng nhập <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="userName"
|
||||||
|
value={formData.userName}
|
||||||
|
onChange={(e) => handleInputChange("userName", e.target.value)}
|
||||||
|
placeholder="Nhập tên đăng nhập (3-20 ký tự, chỉ chữ, số, . và _)"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
{errors.userName && (
|
||||||
|
<p className="text-sm text-destructive">{errors.userName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
Họ và tên <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||||
|
placeholder="Nhập họ và tên đầy đủ"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-sm text-destructive">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
Mật khẩu <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||||
|
placeholder="Nhập mật khẩu (tối thiểu 6 ký tự)"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-destructive">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">
|
||||||
|
Xác nhận mật khẩu <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
|
||||||
|
placeholder="Nhập lại mật khẩu"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="h-10"
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="text-sm text-destructive">{errors.confirmPassword}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="roleId">
|
||||||
|
Vai trò <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
{rolesLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-4 border rounded-md bg-muted/50">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={formData.roleId}
|
||||||
|
onValueChange={(value) => handleInputChange("roleId", value)}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-10">
|
||||||
|
<SelectValue placeholder="Chọn vai trò cho người dùng" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.isArray(roles) &&
|
||||||
|
roles.map((role: any) => (
|
||||||
|
<SelectItem key={role.id} value={String(role.id)}>
|
||||||
|
{role.roleName} (Priority: {role.priority})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{errors.roleId && (
|
||||||
|
<p className="text-sm text-destructive">{errors.roleId}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TODO: Add Room/Building Access selection when API is ready */}
|
||||||
|
{/*
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Quyền truy cập phòng (Tùy chọn)</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Chọn các phòng mà người dùng có quyền truy cập
|
||||||
|
</p>
|
||||||
|
// Add multi-select component here when Room API provides IDs
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate({ to: "/dashboard" })}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="min-w-[100px]"
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className="min-w-[140px]"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Đang tạo...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Tạo tài khoản
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/routes/_auth/user/role/$roleId/index.tsx
Normal file
132
src/routes/_auth/user/role/$roleId/index.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useGetRoleById, useGetRolePermissions } from "@/hooks/queries";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Shield, ArrowLeft, Check, X } from "lucide-react";
|
||||||
|
import type { PermissionOnRole } from "@/types/permission";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/user/role/$roleId/")({
|
||||||
|
component: ViewRolePermissionsComponent,
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
context.breadcrumbs = [
|
||||||
|
{ title: "Quản lý người dùng", path: "#" },
|
||||||
|
{ title: `Quyền của Role #${params.roleId}`, path: `/user/role/${params.roleId}` },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function ViewRolePermissionsComponent() {
|
||||||
|
const { roleId } = Route.useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const roleIdNum = parseInt(roleId, 10);
|
||||||
|
|
||||||
|
const { data: role, isLoading: roleLoading } = useGetRoleById(roleIdNum);
|
||||||
|
const { data: permissions = [], isLoading: permissionsLoading } = useGetRolePermissions(roleIdNum);
|
||||||
|
|
||||||
|
const isLoading = roleLoading || permissionsLoading;
|
||||||
|
|
||||||
|
// Group permissions by parent
|
||||||
|
const groupedPermissions = (permissions as PermissionOnRole[]).reduce((acc, permission) => {
|
||||||
|
if (permission.parentId === null) {
|
||||||
|
if (!acc[permission.permisionId]) {
|
||||||
|
acc[permission.permisionId] = { parent: permission, children: [] };
|
||||||
|
} else {
|
||||||
|
acc[permission.permisionId].parent = permission;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!acc[permission.parentId]) {
|
||||||
|
acc[permission.parentId] = { parent: null as any, children: [] };
|
||||||
|
}
|
||||||
|
acc[permission.parentId].children.push(permission);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, { parent: PermissionOnRole; children: PermissionOnRole[] }>);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: "/dashboard" })}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
Quyền hạn của Role: {role?.roleName || `#${roleId}`}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Danh sách các quyền được gán cho role này
|
||||||
|
{role?.priority !== undefined && (
|
||||||
|
<span className="ml-2">(Độ ưu tiên: <Badge variant="outline">{role.priority}</Badge>)</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{permissions.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">Không có quyền nào được gán cho role này</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.values(groupedPermissions).map(({ parent, children }) => (
|
||||||
|
<div key={parent?.permisionId} className="border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold text-lg">{parent?.permissionName || "Unknown"}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">{parent?.permissionCode}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{parent?.isChecked === 1 ? (
|
||||||
|
<Badge variant="default" className="bg-green-600">
|
||||||
|
<Check className="h-3 w-3 mr-1" />Đã bật
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<X className="h-3 w-3 mr-1" />Đã tắt
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-3 pl-4 border-l-2 border-muted">
|
||||||
|
{children.map((child) => (
|
||||||
|
<div key={child.permisionId} className="flex items-center justify-between p-2 rounded bg-muted/50">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{child.permissionName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{child.permissionCode}</span>
|
||||||
|
</div>
|
||||||
|
{child.isChecked === 1 ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<X className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/routes/_auth/user/role/index.tsx
Normal file
133
src/routes/_auth/user/role/index.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useGetRoleList } from "@/hooks/queries";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Shield, Eye, ArrowLeft, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_auth/user/role/")({
|
||||||
|
component: RoleListComponent,
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
context.breadcrumbs = [
|
||||||
|
{ title: "Quản lý người dùng", path: "#" },
|
||||||
|
{ title: "Danh sách vai trò", path: "/user/role" },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function RoleListComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: roles, isLoading, isError } = useGetRoleList();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 space-y-4">
|
||||||
|
<Card className="max-w-2xl mx-auto">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="text-center text-destructive">
|
||||||
|
Không thể tải danh sách vai trò
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center mt-4">
|
||||||
|
<Button variant="outline" onClick={() => navigate({ to: "/dashboard" })}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => navigate({ to: "/dashboard" })}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Quay lại
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="max-w-4xl mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
Danh sách vai trò
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Quản lý các vai trò và quyền hạn trong hệ thống
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{roles && roles.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Tên vai trò</TableHead>
|
||||||
|
<TableHead>Độ ưu tiên</TableHead>
|
||||||
|
<TableHead>Ngày tạo</TableHead>
|
||||||
|
<TableHead className="text-right">Thao tác</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<TableRow key={role.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{role.id}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{role.roleName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">{role.priority}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{role.createdAt ? new Date(role.createdAt).toLocaleDateString("vi-VN") : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ to: "/user/role/$roleId", params: { roleId: String(role.id) } } as any)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
Xem quyền
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
Không có vai trò nào trong hệ thống
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import axios, { type AxiosProgressEvent } from "axios";
|
import axios, { type AxiosProgressEvent } from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
import type { Version } from "@/types/file";
|
import type { Version } from "@/types/file";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import axios from "axios";
|
import axios from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
import type { LoginResquest, LoginResponse } from "@/types/auth";
|
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Đăng nhập
|
* Đăng nhập
|
||||||
|
|
@ -72,15 +72,9 @@ export async function changePasswordAdmin(data: {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tạo tài khoản mới
|
* Tạo tài khoản mới
|
||||||
* @param data - Dữ liệu tạo tài khoản
|
* @param data - Dữ liệu tạo tài khoản (maps to CreateAccountRequest DTO)
|
||||||
*/
|
*/
|
||||||
export async function createAccount(data: {
|
export async function createAccount(data: CreateAccountRequest): Promise<{ message: string }> {
|
||||||
userName: string;
|
|
||||||
password: string;
|
|
||||||
name: string;
|
|
||||||
roleId: number;
|
|
||||||
accessBuildings?: number[];
|
|
||||||
}): Promise<{ message: string }> {
|
|
||||||
const response = await axios.post(API_ENDPOINTS.AUTH.CREATE_ACCOUNT, data);
|
const response = await axios.post(API_ENDPOINTS.AUTH.CREATE_ACCOUNT, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import axios from "axios";
|
import axios from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import axios from "axios";
|
import axios from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
import type { DeviceHealthCheck } from "@/types/device";
|
import type { DeviceHealthCheck } from "@/types/device";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import axios from "axios";
|
import axios from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
import type { Permission } from "@/types/permission";
|
import type { Permission } from "@/types/permission";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import axios from "axios";
|
import axios from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
import type { TCreateRoleRequestBody, TRoleResponse } from "@/types/role";
|
import type { TCreateRoleRequestBody, TRoleResponse } from "@/types/role";
|
||||||
import type { PermissionOnRole } from "@/types/permission";
|
import type { PermissionOnRole } from "@/types/permission";
|
||||||
|
|
|
||||||
|
|
@ -200,14 +200,16 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex flex-col px-6">
|
<div className="w-full px-6 space-y-4">
|
||||||
<div className="flex-shrink-0 mb-6">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold">{title}</h1>
|
<div>
|
||||||
<p className="text-muted-foreground mt-2">{description}</p>
|
<h1 className="text-3xl font-bold">{title}</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">{description}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-sm flex-1 flex flex-col overflow-hidden">
|
<Card className="w-full">
|
||||||
<CardHeader className="bg-muted/50 flex-shrink-0 flex items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CommandIcon className="h-5 w-5" />
|
<CommandIcon className="h-5 w-5" />
|
||||||
{title}
|
{title}
|
||||||
|
|
@ -221,9 +223,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="p-6 flex-1 flex flex-col overflow-hidden">
|
<CardContent className="space-y-4">
|
||||||
{/* Tabs Navigation */}
|
{/* Tabs Navigation */}
|
||||||
<div className="flex gap-4 mb-6 border-b flex-shrink-0">
|
<div className="flex gap-4 border-b">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("list")}
|
onClick={() => setActiveTab("list")}
|
||||||
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
className={`pb-3 px-2 font-medium text-sm flex items-center gap-2 transition-colors ${
|
||||||
|
|
@ -247,25 +249,22 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
Lệnh thủ công
|
Lệnh thủ công
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab 1: Danh sách */}
|
{/* Tab 1: Danh sách */}
|
||||||
{activeTab === "list" && (
|
{activeTab === "list" && (
|
||||||
<div className="flex-1 flex flex-col space-y-4 overflow-hidden">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex-1 overflow-hidden">
|
<VersionTable<T>
|
||||||
<VersionTable<T>
|
data={data}
|
||||||
data={data}
|
columns={columns}
|
||||||
columns={columns}
|
isLoading={isLoading}
|
||||||
isLoading={isLoading}
|
onTableInit={handleTableInit}
|
||||||
onTableInit={handleTableInit}
|
onRowClick={onRowClick}
|
||||||
onRowClick={onRowClick}
|
scrollable={!enablePagination && scrollable}
|
||||||
scrollable={scrollable}
|
maxHeight={maxHeight}
|
||||||
maxHeight={maxHeight}
|
enablePagination={enablePagination}
|
||||||
enablePagination={enablePagination}
|
defaultPageSize={defaultPageSize}
|
||||||
defaultPageSize={defaultPageSize}
|
pageSizeOptions={pageSizeOptions}
|
||||||
pageSizeOptions={pageSizeOptions}
|
/>
|
||||||
/>
|
<div className="pt-2">
|
||||||
</div>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<RequestUpdateMenu
|
<RequestUpdateMenu
|
||||||
onUpdateDevice={openDeviceDialog}
|
onUpdateDevice={openDeviceDialog}
|
||||||
onUpdateRoom={openRoomDialog}
|
onUpdateRoom={openRoomDialog}
|
||||||
|
|
@ -283,9 +282,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
|
|
||||||
{/* Tab 2: Thực thi */}
|
{/* Tab 2: Thực thi */}
|
||||||
{activeTab === "execute" && (
|
{activeTab === "execute" && (
|
||||||
<div className="flex-1 flex flex-col overflow-auto">
|
<div className="space-y-4">
|
||||||
{/* Lệnh tùy chỉnh */}
|
{/* Lệnh tùy chỉnh */}
|
||||||
<Card className="border-dashed flex-shrink-0">
|
<Card className="border-dashed">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
Thực Thi Lệnh Tùy Chỉnh
|
Thực Thi Lệnh Tùy Chỉnh
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { AppWindow, Building, CircleX, Home, ShieldCheck, Terminal} from "lucide-react";
|
import { AppWindow, Building, CircleX, Home, ShieldCheck, Terminal, UserPlus} from "lucide-react";
|
||||||
import { PermissionEnum } from "./permission";
|
import { PermissionEnum } from "./permission";
|
||||||
|
|
||||||
enum AppSidebarSectionCode {
|
enum AppSidebarSectionCode {
|
||||||
|
|
@ -85,6 +85,12 @@ export const appSidebarSection = {
|
||||||
url: "/role",
|
url: "/role",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
permissions: [PermissionEnum.VIEW_ROLES],
|
permissions: [PermissionEnum.VIEW_ROLES],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tạo người dùng",
|
||||||
|
url: "/user/create",
|
||||||
|
icon: UserPlus,
|
||||||
|
permissions: [PermissionEnum.CRE_USER],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ export interface IAuthContext {
|
||||||
token: string;
|
token: string;
|
||||||
name: string;
|
name: string;
|
||||||
acs: number[];
|
acs: number[];
|
||||||
|
isSystemAdmin?: () => boolean;
|
||||||
role: {
|
role: {
|
||||||
roleName: string;
|
roleName: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
|
@ -28,3 +29,15 @@ export type LoginResponse = {
|
||||||
priority: number;
|
priority: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request DTO for creating new account
|
||||||
|
* Maps to backend CreateAccountRequest DTO
|
||||||
|
*/
|
||||||
|
export type CreateAccountRequest = {
|
||||||
|
name: string;
|
||||||
|
userName: string;
|
||||||
|
password: string;
|
||||||
|
roleId: number;
|
||||||
|
accessRooms?: number[] | null;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user