This commit is contained in:
2025-08-31 22:35:23 -04:00
parent ac51f75b5c
commit 1430c97ae7
36 changed files with 9962 additions and 73 deletions

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
View 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"
]
}
}

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

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

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

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

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

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