-
This commit is contained in:
5926
user/web/package-lock.json
generated
Normal file
5926
user/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
user/web/package.json
Normal file
48
user/web/package.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "user-management",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@mantine/core": "^7.0.0",
|
||||
"@mantine/hooks": "^7.0.0",
|
||||
"@mantine/notifications": "^7.0.0",
|
||||
"@mantine/form": "^7.0.0",
|
||||
"@mantine/modals": "^7.0.0",
|
||||
"@tabler/icons-react": "^2.40.0",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.0",
|
||||
"@babel/preset-react": "^7.22.0",
|
||||
"@babel/preset-typescript": "^7.22.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"babel-loader": "^9.1.0",
|
||||
"css-loader": "^6.8.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"style-loader": "^3.3.0",
|
||||
"typescript": "^5.1.0",
|
||||
"webpack": "^5.88.0",
|
||||
"webpack-cli": "^5.1.0",
|
||||
"webpack-dev-server": "^4.15.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack serve --mode=development",
|
||||
"build": "webpack --mode=production",
|
||||
"dev": "webpack serve --mode=development"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
user/web/public/index.html
Normal file
17
user/web/public/index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="User Management Microservice"
|
||||
/>
|
||||
<title>User Management</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
138
user/web/src/App.tsx
Normal file
138
user/web/src/App.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Title, Tabs, Stack, ActionIcon, Group, Select, MantineProvider } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import {
|
||||
IconUsers,
|
||||
IconUserPlus,
|
||||
IconStar,
|
||||
IconStarFilled
|
||||
} from '@tabler/icons-react';
|
||||
import UserManagement from './components/UserManagement';
|
||||
|
||||
const App: React.FC = () => {
|
||||
// Determine current route based on pathname
|
||||
const getCurrentRoute = () => {
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('/create')) return 'create';
|
||||
return 'users';
|
||||
};
|
||||
|
||||
const [currentRoute, setCurrentRoute] = useState(getCurrentRoute());
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [selectedColor, setSelectedColor] = useState('');
|
||||
|
||||
// Listen for URL changes (for when the shell navigates)
|
||||
React.useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
setCurrentRoute(getCurrentRoute());
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
if (value) {
|
||||
// Use history.pushState to update URL and notify shell router
|
||||
const basePath = '/app/user';
|
||||
const newPath = value === 'users' ? basePath : `${basePath}/${value}`;
|
||||
|
||||
// Update the URL and internal state
|
||||
window.history.pushState(null, '', newPath);
|
||||
setCurrentRoute(value);
|
||||
|
||||
// Dispatch a custom event so shell can respond if needed
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = () => {
|
||||
setIsFavorited(prev => !prev);
|
||||
};
|
||||
|
||||
const colorOptions = [
|
||||
{ value: 'red', label: 'Red' },
|
||||
{ value: 'blue', label: 'Blue' },
|
||||
{ value: 'green', label: 'Green' },
|
||||
{ value: 'purple', label: 'Purple' },
|
||||
{ value: 'orange', label: 'Orange' },
|
||||
{ value: 'pink', label: 'Pink' },
|
||||
{ value: 'teal', label: 'Teal' },
|
||||
];
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentRoute) {
|
||||
case 'users':
|
||||
case 'create':
|
||||
default:
|
||||
return <UserManagement />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MantineProvider>
|
||||
<ModalsProvider>
|
||||
<Notifications />
|
||||
<Box w="100%" pos="relative">
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Group align="center" gap="sm" mb="xs">
|
||||
<Title order={1} size="h2">
|
||||
User Management
|
||||
</Title>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={toggleFavorite}
|
||||
aria-label={isFavorited ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{isFavorited ? (
|
||||
<IconStarFilled size={20} color="gold" />
|
||||
) : (
|
||||
<IconStar size={20} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* Right-side controls */}
|
||||
<Group align="flex-start" gap="lg">
|
||||
<div>
|
||||
<Select
|
||||
placeholder="Choose a color"
|
||||
data={colorOptions}
|
||||
value={selectedColor}
|
||||
onChange={(value) => setSelectedColor(value || '')}
|
||||
size="sm"
|
||||
w={150}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Tabs value={currentRoute} onChange={handleTabChange}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="users"
|
||||
leftSection={<IconUsers size={16} />}
|
||||
>
|
||||
Users
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Box pt="md">
|
||||
{renderContent()}
|
||||
</Box>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Box>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
221
user/web/src/components/UserForm.tsx
Normal file
221
user/web/src/components/UserForm.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
TextInput,
|
||||
Select,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { userService } from '../services/userService';
|
||||
import { User, CreateUserRequest, UpdateUserRequest, UserRole, UserStatus } from '../types/user';
|
||||
|
||||
interface UserFormProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
editUser?: User | null;
|
||||
}
|
||||
|
||||
const UserForm: React.FC<UserFormProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onSuccess,
|
||||
editUser,
|
||||
}) => {
|
||||
const isEditing = !!editUser;
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
display_name: '',
|
||||
avatar: '',
|
||||
role: 'user' as UserRole,
|
||||
status: 'pending' as UserStatus,
|
||||
},
|
||||
validate: {
|
||||
email: (value) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
|
||||
first_name: (value) => (value.trim().length < 1 ? 'First name is required' : null),
|
||||
last_name: (value) => (value.trim().length < 1 ? 'Last name is required' : null),
|
||||
},
|
||||
});
|
||||
|
||||
// Update form values when editUser changes
|
||||
useEffect(() => {
|
||||
if (editUser) {
|
||||
form.setValues({
|
||||
email: editUser.email || '',
|
||||
first_name: editUser.first_name || '',
|
||||
last_name: editUser.last_name || '',
|
||||
display_name: editUser.display_name || '',
|
||||
avatar: editUser.avatar || '',
|
||||
role: editUser.role || 'user' as UserRole,
|
||||
status: editUser.status || 'pending' as UserStatus,
|
||||
});
|
||||
} else {
|
||||
// Reset to default values when not editing
|
||||
form.setValues({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
display_name: '',
|
||||
avatar: '',
|
||||
role: 'user' as UserRole,
|
||||
status: 'pending' as UserStatus,
|
||||
});
|
||||
}
|
||||
}, [editUser, opened]);
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
try {
|
||||
if (isEditing && editUser) {
|
||||
const updateRequest: UpdateUserRequest = {
|
||||
email: values.email !== editUser.email ? values.email : undefined,
|
||||
first_name: values.first_name !== editUser.first_name ? values.first_name : undefined,
|
||||
last_name: values.last_name !== editUser.last_name ? values.last_name : undefined,
|
||||
display_name: values.display_name !== editUser.display_name ? values.display_name : undefined,
|
||||
avatar: values.avatar !== editUser.avatar ? values.avatar : undefined,
|
||||
role: values.role !== editUser.role ? values.role : undefined,
|
||||
status: values.status !== editUser.status ? values.status : undefined,
|
||||
};
|
||||
|
||||
// Only send fields that have changed
|
||||
const hasChanges = Object.values(updateRequest).some(value => value !== undefined);
|
||||
if (!hasChanges) {
|
||||
notifications.show({
|
||||
title: 'No Changes',
|
||||
message: 'No changes detected',
|
||||
color: 'blue',
|
||||
});
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
await userService.updateUser(editUser.id, updateRequest);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User updated successfully',
|
||||
color: 'green',
|
||||
});
|
||||
} else {
|
||||
const createRequest: CreateUserRequest = {
|
||||
email: values.email,
|
||||
first_name: values.first_name,
|
||||
last_name: values.last_name,
|
||||
display_name: values.display_name || undefined,
|
||||
avatar: values.avatar || undefined,
|
||||
role: values.role,
|
||||
status: values.status,
|
||||
};
|
||||
|
||||
await userService.createUser(createRequest);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User created successfully',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
form.reset();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save user:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error.message || 'Failed to save user',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={isEditing ? 'Edit User' : 'Create User'}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="First Name"
|
||||
placeholder="Enter first name"
|
||||
required
|
||||
{...form.getInputProps('first_name')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Last Name"
|
||||
placeholder="Enter last name"
|
||||
required
|
||||
{...form.getInputProps('last_name')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="Display Name"
|
||||
placeholder="Enter display name (optional)"
|
||||
{...form.getInputProps('display_name')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
placeholder="Enter email address"
|
||||
required
|
||||
type="email"
|
||||
{...form.getInputProps('email')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Avatar URL"
|
||||
placeholder="Enter avatar URL (optional)"
|
||||
{...form.getInputProps('avatar')}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Role"
|
||||
placeholder="Select role"
|
||||
required
|
||||
data={[
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'moderator', label: 'Moderator' },
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
]}
|
||||
{...form.getInputProps('role')}
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
required
|
||||
data={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
]}
|
||||
{...form.getInputProps('status')}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{isEditing ? 'Update' : 'Create'} User
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserForm;
|
||||
310
user/web/src/components/UserManagement.tsx
Normal file
310
user/web/src/components/UserManagement.tsx
Normal file
@ -0,0 +1,310 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
TextInput,
|
||||
Select,
|
||||
Pagination,
|
||||
Stack,
|
||||
LoadingOverlay,
|
||||
Avatar,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconSearch,
|
||||
IconUser,
|
||||
IconMail,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { modals } from '@mantine/modals';
|
||||
import UserForm from './UserForm';
|
||||
import { userService } from '../services/userService';
|
||||
import { User, UserStatus, UserRole, ListUsersRequest } from '../types/user';
|
||||
|
||||
const UserManagement: React.FC = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [roleFilter, setRoleFilter] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [userFormOpened, setUserFormOpened] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
const loadUsers = async (page: number = currentPage) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const request: ListUsersRequest = {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter as UserStatus || undefined,
|
||||
role: roleFilter as UserRole || undefined,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order_by: 'created_at',
|
||||
order_dir: 'desc',
|
||||
};
|
||||
|
||||
const response = await userService.listUsers(request);
|
||||
setUsers(response.users);
|
||||
setTotalUsers(response.total);
|
||||
setCurrentPage(page);
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to load users',
|
||||
color: 'red',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers(1);
|
||||
}, [searchTerm, statusFilter, roleFilter]);
|
||||
|
||||
const handleCreateUser = () => {
|
||||
setEditingUser(null);
|
||||
setUserFormOpened(true);
|
||||
};
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setUserFormOpened(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = (user: User) => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Delete User',
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to delete {user.first_name} {user.last_name}? This action cannot be undone.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Delete', cancel: 'Cancel' },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await userService.deleteUser(user.id);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'User deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Failed to delete user',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUserFormSuccess = () => {
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleUserFormClose = () => {
|
||||
setUserFormOpened(false);
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: UserStatus): string => {
|
||||
switch (status) {
|
||||
case 'active': return 'green';
|
||||
case 'inactive': return 'gray';
|
||||
case 'suspended': return 'red';
|
||||
case 'pending': return 'yellow';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role: UserRole): string => {
|
||||
switch (role) {
|
||||
case 'admin': return 'red';
|
||||
case 'moderator': return 'orange';
|
||||
case 'user': return 'blue';
|
||||
case 'viewer': return 'gray';
|
||||
default: return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main user management interface */}
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={handleCreateUser}>
|
||||
Add User
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Group gap="sm">
|
||||
<TextInput
|
||||
placeholder="Search users..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.currentTarget.value)}
|
||||
style={{ minWidth: 250 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Filter by status"
|
||||
data={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
]}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
placeholder="Filter by role"
|
||||
data={[
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'moderator', label: 'Moderator' },
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
]}
|
||||
value={roleFilter}
|
||||
onChange={setRoleFilter}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{totalUsers} users found
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>User</Table.Th>
|
||||
<Table.Th>Email</Table.Th>
|
||||
<Table.Th>Role</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
<Table.Th>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{users.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="sm">
|
||||
<Avatar
|
||||
src={user.avatar || null}
|
||||
radius="sm"
|
||||
size={32}
|
||||
>
|
||||
<IconUser size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{user.display_name || `${user.first_name} ${user.last_name}`}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{user.first_name} {user.last_name}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<IconMail size={14} />
|
||||
<Text size="sm">{user.email}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={getRoleColor(user.role)} variant="light">
|
||||
{user.role}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={getStatusColor(user.status)} variant="light">
|
||||
{user.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => handleEditUser(user)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{users.length === 0 && !loading && (
|
||||
<Text ta="center" py="xl" c="dimmed">
|
||||
No users found
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center" mt="md">
|
||||
<Pagination
|
||||
value={currentPage}
|
||||
onChange={loadUsers}
|
||||
total={totalPages}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{/* User form modal */}
|
||||
<UserForm
|
||||
opened={userFormOpened}
|
||||
onClose={handleUserFormClose}
|
||||
onSuccess={handleUserFormSuccess}
|
||||
editUser={editingUser}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
13
user/web/src/index.tsx
Normal file
13
user/web/src/index.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
109
user/web/src/services/userService.ts
Normal file
109
user/web/src/services/userService.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import {
|
||||
User,
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
ListUsersRequest,
|
||||
ListUsersResponse,
|
||||
ExistsByEmailResponse,
|
||||
} from '../types/user';
|
||||
|
||||
class UserService {
|
||||
private api: AxiosInstance;
|
||||
private baseURL: string;
|
||||
|
||||
constructor() {
|
||||
this.baseURL = process.env.REACT_APP_USER_API_URL || 'http://localhost:8090';
|
||||
|
||||
this.api = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor for authentication
|
||||
this.api.interceptors.request.use((config) => {
|
||||
// For development, use header-based authentication
|
||||
// In production, this might use JWT tokens or other auth mechanisms
|
||||
const userEmail = 'admin@example.com'; // This would come from auth context
|
||||
config.headers['X-User-Email'] = userEmail;
|
||||
return config;
|
||||
});
|
||||
|
||||
// Add response interceptor for error handling
|
||||
this.api.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error) => {
|
||||
console.error('API Error:', error);
|
||||
if (error.response?.data?.error) {
|
||||
throw new Error(error.response.data.error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async createUser(userData: CreateUserRequest): Promise<User> {
|
||||
const response = await this.api.post<User>('/api/users', userData);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User> {
|
||||
const response = await this.api.get<User>(`/api/users/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User> {
|
||||
const response = await this.api.get<User>(`/api/users/email/${encodeURIComponent(email)}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateUser(id: string, userData: UpdateUserRequest): Promise<User> {
|
||||
const response = await this.api.put<User>(`/api/users/${id}`, userData);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteUser(id: string): Promise<void> {
|
||||
await this.api.delete(`/api/users/${id}`);
|
||||
}
|
||||
|
||||
async listUsers(request: ListUsersRequest = {}): Promise<ListUsersResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (request.status) params.append('status', request.status);
|
||||
if (request.role) params.append('role', request.role);
|
||||
if (request.search) params.append('search', request.search);
|
||||
if (request.limit) params.append('limit', request.limit.toString());
|
||||
if (request.offset) params.append('offset', request.offset.toString());
|
||||
if (request.order_by) params.append('order_by', request.order_by);
|
||||
if (request.order_dir) params.append('order_dir', request.order_dir);
|
||||
|
||||
const response = await this.api.get<ListUsersResponse>(`/api/users?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async existsByEmail(email: string): Promise<ExistsByEmailResponse> {
|
||||
const response = await this.api.get<ExistsByEmailResponse>(`/api/users/exists/${encodeURIComponent(email)}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async health(): Promise<Record<string, any>> {
|
||||
const response = await this.api.get<Record<string, any>>('/health');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Utility method to check service availability
|
||||
async isServiceAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await this.health();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('User service is not available:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const userService = new UserService();
|
||||
82
user/web/src/types/user.ts
Normal file
82
user/web/src/types/user.ts
Normal file
@ -0,0 +1,82 @@
|
||||
export type UserStatus = 'active' | 'inactive' | 'suspended' | 'pending';
|
||||
export type UserRole = 'admin' | 'user' | 'moderator' | 'viewer';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
display_name?: string;
|
||||
avatar?: string;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
last_login_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
user_id: string;
|
||||
bio?: string;
|
||||
location?: string;
|
||||
website?: string;
|
||||
timezone?: string;
|
||||
language?: string;
|
||||
preferences?: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
display_name?: string;
|
||||
avatar?: string;
|
||||
role: UserRole;
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
email?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
display_name?: string;
|
||||
avatar?: string;
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
export interface UpdateUserProfileRequest {
|
||||
bio?: string;
|
||||
location?: string;
|
||||
website?: string;
|
||||
timezone?: string;
|
||||
language?: string;
|
||||
preferences?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ListUsersRequest {
|
||||
status?: UserStatus;
|
||||
role?: UserRole;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
order_by?: string;
|
||||
order_dir?: string;
|
||||
}
|
||||
|
||||
export interface ListUsersResponse {
|
||||
users: User[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface ExistsByEmailResponse {
|
||||
exists: boolean;
|
||||
email: string;
|
||||
}
|
||||
87
user/web/webpack.config.js
Normal file
87
user/web/webpack.config.js
Normal file
@ -0,0 +1,87 @@
|
||||
const path = require('path');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const { ModuleFederationPlugin } = require('webpack').container;
|
||||
const webpack = require('webpack');
|
||||
|
||||
// Import the microfrontends registry
|
||||
const { getExposesConfig } = require('../../web/src/microfrontends.js');
|
||||
|
||||
module.exports = {
|
||||
mode: 'development',
|
||||
entry: './src/index.tsx',
|
||||
devServer: {
|
||||
port: 3004,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js', '.jsx'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|jsx|ts|tsx)$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: [
|
||||
'@babel/preset-react',
|
||||
'@babel/preset-typescript',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new ModuleFederationPlugin({
|
||||
name: 'user',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: getExposesConfig('user'),
|
||||
shared: {
|
||||
react: {
|
||||
singleton: true,
|
||||
requiredVersion: '^18.2.0',
|
||||
eager: false,
|
||||
},
|
||||
'react-dom': {
|
||||
singleton: true,
|
||||
requiredVersion: '^18.2.0',
|
||||
eager: false,
|
||||
},
|
||||
'@mantine/core': {
|
||||
singleton: true,
|
||||
requiredVersion: '^7.0.0',
|
||||
eager: false,
|
||||
},
|
||||
'@mantine/hooks': {
|
||||
singleton: true,
|
||||
requiredVersion: '^7.0.0',
|
||||
eager: false,
|
||||
},
|
||||
'@mantine/notifications': {
|
||||
singleton: true,
|
||||
requiredVersion: '^7.0.0',
|
||||
eager: false,
|
||||
},
|
||||
'@tabler/icons-react': {
|
||||
singleton: true,
|
||||
requiredVersion: '^2.40.0',
|
||||
eager: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: './public/index.html',
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env': JSON.stringify(process.env),
|
||||
}),
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user