feat: Implement user and policy management UI
- Add comprehensive user management with CRUD operations - Implement policy management with templates and custom JSON - Create reusable components (ConfirmDialog, LoadingState, EmptyState) - Add user creation with password validation and credential copying - Implement policy creation with built-in templates - Add policy attachment to users functionality - Include search, filtering, and status management - Add proper error handling and validation - Implement Material-UI based responsive design 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Typography,
|
||||
Alert,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
ContentCopy,
|
||||
} from '@mui/icons-material';
|
||||
import userService from '../../services/userService';
|
||||
|
||||
interface CreateUserDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const CreateUserDialog: React.FC<CreateUserDialogProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
const passwordValidation = userService.validatePassword(password);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!username.trim()) {
|
||||
setError('Username is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
setError('Please fix password validation errors');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await userService.createUser({
|
||||
accessKey: username,
|
||||
secretKey: password,
|
||||
});
|
||||
setShowSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCredentials = () => {
|
||||
const credentials = `Username: ${username}\nPassword: ${password}`;
|
||||
navigator.clipboard.writeText(credentials);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!showSuccess) {
|
||||
onClose();
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccessClose = () => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setShowPassword(false);
|
||||
setError('');
|
||||
setShowSuccess(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getValidationIcon = (isValid: boolean) => {
|
||||
return isValid ? (
|
||||
<CheckCircle color="success" fontSize="small" />
|
||||
) : (
|
||||
<Cancel color="error" fontSize="small" />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogTitle>
|
||||
{showSuccess ? 'User Created Successfully' : 'Create New User'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!showSuccess ? (
|
||||
<>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
label="Username"
|
||||
fullWidth
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
margin="dense"
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
fullWidth
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{password && (
|
||||
<Box mt={2}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Password Requirements:
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{getValidationIcon(password.length >= 8)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="At least 8 characters"
|
||||
primaryTypographyProps={{
|
||||
color: password.length >= 8 ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{getValidationIcon(/[A-Z]/.test(password))}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="At least one uppercase letter"
|
||||
primaryTypographyProps={{
|
||||
color: /[A-Z]/.test(password) ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{getValidationIcon(/[a-z]/.test(password))}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="At least one lowercase letter"
|
||||
primaryTypographyProps={{
|
||||
color: /[a-z]/.test(password) ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{getValidationIcon(/[0-9]/.test(password))}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="At least one number"
|
||||
primaryTypographyProps={{
|
||||
color: /[0-9]/.test(password) ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box>
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
User created successfully! Save these credentials securely - the password cannot be retrieved later.
|
||||
</Alert>
|
||||
<Box sx={{ bgcolor: 'grey.100', p: 2, borderRadius: 1, mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
<strong>Username:</strong> {username}
|
||||
<br />
|
||||
<strong>Password:</strong> {password}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title="Copy credentials to clipboard">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ContentCopy />}
|
||||
onClick={handleCopyCredentials}
|
||||
fullWidth
|
||||
>
|
||||
Copy Credentials
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
{!showSuccess ? (
|
||||
<>
|
||||
<Button onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={loading || !passwordValidation.valid}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleSuccessClose} variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUserDialog;
|
||||
@@ -1,15 +1,266 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Button,
|
||||
Typography,
|
||||
Chip,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Switch,
|
||||
Tooltip,
|
||||
Alert,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Delete,
|
||||
Search,
|
||||
PersonAdd,
|
||||
Person,
|
||||
} from '@mui/icons-material';
|
||||
import LoadingState from '../shared/LoadingState';
|
||||
import EmptyState from '../shared/EmptyState';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import CreateUserDialog from './CreateUserDialog';
|
||||
import userService, { User } from '../../services/userService';
|
||||
|
||||
const Users: React.FC = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
||||
const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' as 'success' | 'error' });
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const userList = await userService.listUsers();
|
||||
setUsers(userList);
|
||||
} catch (error) {
|
||||
showSnackbar(error instanceof Error ? error.message : 'Failed to load users', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = () => {
|
||||
setCreateDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = (accessKey: string) => {
|
||||
setUserToDelete(accessKey);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteUser = async () => {
|
||||
if (!userToDelete) return;
|
||||
|
||||
try {
|
||||
await userService.deleteUser(userToDelete);
|
||||
showSnackbar('User deleted successfully', 'success');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showSnackbar(error instanceof Error ? error.message : 'Failed to delete user', 'error');
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setUserToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUserStatus = async (accessKey: string, currentStatus: string) => {
|
||||
try {
|
||||
const newStatus = currentStatus === 'enabled' ? 'disabled' : 'enabled';
|
||||
await userService.updateUserStatus(accessKey, newStatus);
|
||||
showSnackbar(`User ${newStatus} successfully`, 'success');
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
showSnackbar(error instanceof Error ? error.message : 'Failed to update user status', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const showSnackbar = (message: string, severity: 'success' | 'error') => {
|
||||
setSnackbar({ open: true, message, severity });
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter((user) =>
|
||||
user.accessKey.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState message="Loading users..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Users
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
User management functionality coming soon...
|
||||
</Typography>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h4">User Management</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonAdd />}
|
||||
onClick={handleCreateUser}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper>
|
||||
<Box p={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Search users..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{filteredUsers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Person />}
|
||||
title={searchTerm ? 'No users found' : 'No users yet'}
|
||||
message={
|
||||
searchTerm
|
||||
? 'Try adjusting your search criteria'
|
||||
: 'Create your first user to get started'
|
||||
}
|
||||
actionLabel={!searchTerm ? 'Create User' : undefined}
|
||||
onAction={!searchTerm ? handleCreateUser : undefined}
|
||||
/>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Policy</TableCell>
|
||||
<TableCell>Groups</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.accessKey}>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Person fontSize="small" color="action" />
|
||||
<Typography variant="body2">{user.accessKey}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={user.status}
|
||||
color={user.status === 'enabled' ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.policyName ? (
|
||||
<Chip label={user.policyName} size="small" variant="outlined" />
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No policy
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.memberOf && user.memberOf.length > 0 ? (
|
||||
user.memberOf.map((group) => (
|
||||
<Chip
|
||||
key={group}
|
||||
label={group}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mr: 0.5 }}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No groups
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title={user.status === 'enabled' ? 'Disable user' : 'Enable user'}>
|
||||
<Switch
|
||||
checked={user.status === 'enabled'}
|
||||
onChange={() => handleToggleUserStatus(user.accessKey, user.status)}
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete user">
|
||||
<IconButton
|
||||
onClick={() => handleDeleteUser(user.accessKey)}
|
||||
color="error"
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<CreateUserDialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onSuccess={() => {
|
||||
setCreateDialogOpen(false);
|
||||
loadUsers();
|
||||
showSnackbar('User created successfully', 'success');
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
title="Delete User"
|
||||
message={`Are you sure you want to delete user "${userToDelete}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
confirmColor="error"
|
||||
onConfirm={confirmDeleteUser}
|
||||
onCancel={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setUserToDelete(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
severity={snackbar.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Users';
|
||||
Reference in New Issue
Block a user