complete role and create user route

This commit is contained in:
Do Manh Phuong 2026-03-06 17:54:09 +07:00
parent 3776ce6e22
commit df49bde2c4
28 changed files with 1586 additions and 119 deletions

View 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!

View File

@ -7,7 +7,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 {
username: string;
@ -18,6 +18,7 @@ interface AvatarDropdownProps {
onLogOut: () => void;
onSettings?: () => void;
onProfile?: () => void;
onChangePassword?: () => void;
}
export default function AvatarDropdown({
@ -26,6 +27,7 @@ export default function AvatarDropdown({
onLogOut,
onSettings,
onProfile,
onChangePassword,
}: AvatarDropdownProps) {
// Get initials from username
const getInitials = (name: string): string => {
@ -62,7 +64,13 @@ export default function AvatarDropdown({
{onProfile && (
<DropdownMenuItem onClick={onProfile} className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
<span>Tài khoản của tôi</span>
<span>Thông tin 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>
)}
{onSettings && (
@ -71,7 +79,7 @@ export default function AvatarDropdown({
<span>Cài đt</span>
</DropdownMenuItem>
)}
{(onProfile || onSettings) && <DropdownMenuSeparator />}
{(onProfile || onChangePassword || onSettings) && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={onLogOut}
className="cursor-pointer text-destructive focus:text-destructive"

View File

@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { useForm, formOptions } from "@tanstack/react-form";
import axios from "axios";
import axios from "@/config/axios";
interface AddBlacklistDialogProps {
onAdded?: () => void; // callback để refresh danh sách sau khi thêm

View File

@ -21,12 +21,8 @@ export function CustomPagination({
onPageSizeChange,
pageSizeOptions
}: PaginationProps) {
let startItem = (currentPage - 1) * itemsPerPage + 1;
let endItem = Math.min(currentPage * itemsPerPage, totalItems);
if (currentPage === totalPages) {
startItem = totalItems - itemsPerPage + 1;
endItem = totalItems;
}
const startItem = Math.max(1, (currentPage - 1) * itemsPerPage + 1);
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<div className="flex flex-col sm:flex-row items-center gap-4">

View File

@ -13,6 +13,12 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { appSidebarSection } from "@/types/app-sidebar";
import { PermissionEnum } from "@/types/permission";
@ -33,10 +39,10 @@ type SidebarSection = {
};
export function AppSidebar() {
const { hasPermission, acs } = useAuth();
const { hasPermission, acs, isSystemAdmin } = useAuth();
// Check if user is admin (has ALLOW_ALL permission)
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL);
// Check if user is admin (has ALLOW_ALL permission OR is System Admin with priority 0)
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL) || isSystemAdmin();
// Check if user has any of the required permissions
const checkPermissions = (permissions?: PermissionEnum[]) => {
@ -44,7 +50,7 @@ export function AppSidebar() {
if (!permissions || permissions.length === 0) return true;
// Item marked as ALLOW_ALL = show to everyone
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;
// Check if user has any of the required permissions
return permissions.some((permission) => hasPermission(permission));
@ -62,10 +68,11 @@ export function AppSidebar() {
}, [acs]);
return (
<Sidebar
collapsible="icon"
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<TooltipProvider delayDuration={300}>
<Sidebar
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">
<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">
@ -93,29 +100,36 @@ export function AppSidebar() {
<SidebarMenu className="space-y-1">
{section.items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
"transition-all duration-200 ease-in-out",
"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}
className="flex items-center gap-3 w-full"
>
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
<span className="font-medium text-sm truncate">
{item.title}
</span>
</Link>
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
"transition-all duration-200 ease-in-out",
"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}
className="flex items-center gap-3 w-full"
>
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
<span className="font-medium text-sm truncate">
{item.title}
</span>
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right" className="font-medium">
{item.title}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
))}
</SidebarMenu>
@ -130,5 +144,6 @@ export function AppSidebar() {
</div>
</SidebarFooter>
</Sidebar>
</TooltipProvider>
);
}

View File

@ -61,47 +61,21 @@ export const API_ENDPOINTS = {
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
},
PERMISSION: {
// Lấy danh sách permission từ enum
GET_LIST: `${BASE_URL}/Permission/list`,
// Lấy permission theo 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}`,
// Import permission từ enum vào DB (chạy 1 lần đầu tiên)
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`,
// Xóa permission
DELETE: (id: number) => `${BASE_URL}/Permission/${id}`,
},
ROLE: {
// Lấy danh sách tất cả roles
GET_LIST: `${BASE_URL}/Role/list`,
// Lấy chi tiết role theo ID
GET_BY_ID: (id: number) => `${BASE_URL}/Role/${id}`,
// Tạo role mới
CREATE: `${BASE_URL}/Role/create`,
// Cập nhật role
UPDATE: (id: number) => `${BASE_URL}/Role/update/${id}`,
// Xóa role
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`,
// Gán permissions cho role (thay thế toàn bộ)
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) =>
`${BASE_URL}/Role/${roleId}/permissions/${permissionId}/toggle`,
},

49
src/config/axios.ts Normal file
View 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ỗ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
View 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;

View 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}`;
}

View File

@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as authService from "@/services/auth.service";
import type { CreateAccountRequest } from "@/types/auth";
import type { LoginResquest, LoginResponse } from "@/types/auth";
const AUTH_QUERY_KEYS = {
@ -105,13 +106,7 @@ export function useCreateAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
userName: string;
password: string;
name: string;
roleId: number;
accessBuildings?: number[];
}) => authService.createAccount(data),
mutationFn: (data: CreateAccountRequest) => authService.createAccount(data),
onSuccess: () => {
// Có thể invalidate user list query nếu có
queryClient.invalidateQueries({ queryKey: ["users"] });

View File

@ -1,5 +1,6 @@
import { sleep } from "@/lib/utils";
import { PermissionEnum } from "@/types/permission";
import { SYSTEM_ADMIN_PRIORITY } from "@/config/constants";
import React, { useContext, useEffect, useMemo } from "react";
import { useCallback, useState } from "react";
@ -13,6 +14,7 @@ export interface IAuthContext {
name: string;
acs: number[];
hasPermission: (permission: PermissionEnum) => boolean;
isSystemAdmin: () => boolean;
role: {
roleName: string;
priority: number;
@ -61,6 +63,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
[acs]
);
const isSystemAdmin = useCallback(() => {
return Number(priority) === SYSTEM_ADMIN_PRIORITY;
}, [priority]);
const logout = useCallback(async () => {
await sleep(250);
setAuthenticated(false);
@ -89,7 +95,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
name,
acs,
role: { roleName, priority: Number(priority) },
hasPermission
hasPermission,
isSystemAdmin
}}>
{children}
</AuthContext.Provider>

View File

@ -20,8 +20,14 @@ import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklis
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/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 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'
const AuthRoute = AuthRouteImport.update({
@ -78,6 +84,16 @@ const authLoginIndexRoute = authLoginIndexRouteImport.update({
path: '/login/',
getParentRoute: () => rootRouteImport,
} 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({
id: '/rooms/$roomName/',
path: '/rooms/$roomName/',
@ -88,6 +104,29 @@ const AuthRoleCreateIndexRoute = AuthRoleCreateIndexRouteImport.update({
path: '/role/create/',
getParentRoute: () => AuthRoute,
} 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({
id: '/role/$id/edit/',
path: '/role/$id/edit/',
@ -105,9 +144,15 @@ export interface FileRoutesByFullPath {
'/device': typeof AuthDeviceIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/user/role': typeof AuthUserRoleIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@ -120,9 +165,15 @@ export interface FileRoutesByTo {
'/device': typeof AuthDeviceIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/user/role': typeof AuthUserRoleIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@ -137,9 +188,15 @@ export interface FileRoutesById {
'/_auth/device/': typeof AuthDeviceIndexRoute
'/_auth/role/': typeof AuthRoleIndexRoute
'/_auth/rooms/': typeof AuthRoomsIndexRoute
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
'/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute
'/_auth/user/create/': typeof AuthUserCreateIndexRoute
'/_auth/user/role/': typeof AuthUserRoleIndexRoute
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -154,9 +211,15 @@ export interface FileRouteTypes {
| '/device'
| '/role'
| '/rooms'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/user/role'
| '/role/$id/edit'
| '/user/change-password/$userName'
| '/user/role/$roleId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -169,9 +232,15 @@ export interface FileRouteTypes {
| '/device'
| '/role'
| '/rooms'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/user/role'
| '/role/$id/edit'
| '/user/change-password/$userName'
| '/user/role/$roleId'
id:
| '__root__'
| '/'
@ -185,9 +254,15 @@ export interface FileRouteTypes {
| '/_auth/device/'
| '/_auth/role/'
| '/_auth/rooms/'
| '/_auth/profile/$userName/'
| '/_auth/profile/change-password/'
| '/_auth/role/create/'
| '/_auth/rooms/$roomName/'
| '/_auth/user/create/'
| '/_auth/user/role/'
| '/_auth/role/$id/edit/'
| '/_auth/user/change-password/$userName/'
| '/_auth/user/role/$roleId/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -275,6 +350,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof authLoginIndexRouteImport
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/': {
id: '/_auth/rooms/$roomName/'
path: '/rooms/$roomName'
@ -289,6 +378,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthRoleCreateIndexRouteImport
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/': {
id: '/_auth/role/$id/edit/'
path: '/role/$id/edit'
@ -308,9 +425,15 @@ interface AuthRouteChildren {
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
AuthProfileUserNameIndexRoute: typeof AuthProfileUserNameIndexRoute
AuthProfileChangePasswordIndexRoute: typeof AuthProfileChangePasswordIndexRoute
AuthRoleCreateIndexRoute: typeof AuthRoleCreateIndexRoute
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
AuthUserRoleIndexRoute: typeof AuthUserRoleIndexRoute
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
@ -322,9 +445,16 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
AuthRoleIndexRoute: AuthRoleIndexRoute,
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
AuthProfileUserNameIndexRoute: AuthProfileUserNameIndexRoute,
AuthProfileChangePasswordIndexRoute: AuthProfileChangePasswordIndexRoute,
AuthRoleCreateIndexRoute: AuthRoleCreateIndexRoute,
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
AuthUserRoleIndexRoute: AuthUserRoleIndexRoute,
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
AuthUserChangePasswordUserNameIndexRoute:
AuthUserChangePasswordUserNameIndexRoute,
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)

View File

@ -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;
if (!auth.isAuthenticated) {
@ -61,7 +69,13 @@ function RouteComponent() {
<AppBreadCrumb />
<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>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 overflow-x-auto">

View 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 quyền xem hồ 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ọ 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>
);
}

View 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>
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>
);
}

View 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>
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>
);
}

View 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ọ 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 người dùng 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>
);
}

View 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 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>
);
}

View 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 các vai trò 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 vai trò nào trong hệ thống
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,4 +1,4 @@
import axios, { type AxiosProgressEvent } from "axios";
import axios, { type AxiosProgressEvent } from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { Version } from "@/types/file";

View File

@ -1,6 +1,6 @@
import axios from "axios";
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { LoginResquest, LoginResponse } from "@/types/auth";
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
/**
* Đăng nhập
@ -72,15 +72,9 @@ export async function changePasswordAdmin(data: {
/**
* 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: {
userName: string;
password: string;
name: string;
roleId: number;
accessBuildings?: number[];
}): Promise<{ message: string }> {
export async function createAccount(data: CreateAccountRequest): Promise<{ message: string }> {
const response = await axios.post(API_ENDPOINTS.AUTH.CREATE_ACCOUNT, data);
return response.data;
}

View File

@ -1,4 +1,4 @@
import axios from "axios";
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
/**

View File

@ -1,4 +1,4 @@
import axios from "axios";
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { DeviceHealthCheck } from "@/types/device";

View File

@ -1,4 +1,4 @@
import axios from "axios";
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { Permission } from "@/types/permission";

View File

@ -1,4 +1,4 @@
import axios from "axios";
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { TCreateRoleRequestBody, TRoleResponse } from "@/types/role";
import type { PermissionOnRole } from "@/types/permission";

View File

@ -200,14 +200,16 @@ export function CommandSubmitTemplate<T extends { id: number }>({
};
return (
<div className="w-full h-full flex flex-col px-6">
<div className="flex-shrink-0 mb-6">
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
<div className="w-full px-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{title}</h1>
<p className="text-muted-foreground mt-2">{description}</p>
</div>
</div>
<Card className="shadow-sm flex-1 flex flex-col overflow-hidden">
<CardHeader className="bg-muted/50 flex-shrink-0 flex items-center justify-between">
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CommandIcon className="h-5 w-5" />
{title}
@ -221,9 +223,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
)}
</CardHeader>
<CardContent className="p-6 flex-1 flex flex-col overflow-hidden">
<CardContent className="space-y-4">
{/* Tabs Navigation */}
<div className="flex gap-4 mb-6 border-b flex-shrink-0">
<div className="flex gap-4 border-b">
<button
onClick={() => setActiveTab("list")}
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
</button>
</div>
{/* Tab 1: Danh sách */}
{activeTab === "list" && (
<div className="flex-1 flex flex-col space-y-4 overflow-hidden">
<div className="flex-1 overflow-hidden">
<VersionTable<T>
data={data}
columns={columns}
isLoading={isLoading}
onTableInit={handleTableInit}
onRowClick={onRowClick}
scrollable={scrollable}
maxHeight={maxHeight}
enablePagination={enablePagination}
defaultPageSize={defaultPageSize}
pageSizeOptions={pageSizeOptions}
/>
</div>
<div className="flex-shrink-0">
<div className="flex flex-col gap-4">
<VersionTable<T>
data={data}
columns={columns}
isLoading={isLoading}
onTableInit={handleTableInit}
onRowClick={onRowClick}
scrollable={!enablePagination && scrollable}
maxHeight={maxHeight}
enablePagination={enablePagination}
defaultPageSize={defaultPageSize}
pageSizeOptions={pageSizeOptions}
/>
<div className="pt-2">
<RequestUpdateMenu
onUpdateDevice={openDeviceDialog}
onUpdateRoom={openRoomDialog}
@ -283,9 +282,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
{/* Tab 2: Thực thi */}
{activeTab === "execute" && (
<div className="flex-1 flex flex-col overflow-auto">
<div className="space-y-4">
{/* Lệnh tùy chỉnh */}
<Card className="border-dashed flex-shrink-0">
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-base">
Thực Thi Lệnh Tùy Chỉnh

View File

@ -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";
enum AppSidebarSectionCode {
@ -85,6 +85,12 @@ export const appSidebarSection = {
url: "/role",
icon: ShieldCheck,
permissions: [PermissionEnum.VIEW_ROLES],
},
{
title: "Tạo người dùng",
url: "/user/create",
icon: UserPlus,
permissions: [PermissionEnum.CRE_USER],
}
]
}

View File

@ -5,6 +5,7 @@ export interface IAuthContext {
token: string;
name: string;
acs: number[];
isSystemAdmin?: () => boolean;
role: {
roleName: string;
priority: number;
@ -28,3 +29,15 @@ export type LoginResponse = {
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;
};