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 <noreply@anthropic.com>
This commit is contained in:
2025-07-23 16:45:39 +02:00
parent a91d56751c
commit 55983d0088
13 changed files with 1818 additions and 16 deletions
@@ -0,0 +1,182 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
Alert,
Box,
Typography,
} from '@mui/material';
import policyService from '../../services/policyService';
import userService from '../../services/userService';
interface AttachPolicyDialogProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
initialPolicy?: string;
initialUser?: string;
}
const AttachPolicyDialog: React.FC<AttachPolicyDialogProps> = ({
open,
onClose,
onSuccess,
initialPolicy,
initialUser,
}) => {
const [selectedPolicy, setSelectedPolicy] = useState(initialPolicy || '');
const [selectedUser, setSelectedUser] = useState(initialUser || '');
const [policies, setPolicies] = useState<string[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [loadingData, setLoadingData] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
loadData();
}
}, [open]);
useEffect(() => {
if (initialPolicy) {
setSelectedPolicy(initialPolicy);
}
if (initialUser) {
setSelectedUser(initialUser);
}
}, [initialPolicy, initialUser]);
const loadData = async () => {
try {
setLoadingData(true);
const [policiesData, usersData] = await Promise.all([
policyService.listPolicies(),
userService.listUsers(),
]);
setPolicies(policiesData.map(p => p.name));
setUsers(usersData.map(u => u.accessKey));
} catch (err) {
setError('Failed to load data');
} finally {
setLoadingData(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!selectedPolicy || !selectedUser) {
setError('Please select both a policy and a user');
return;
}
setLoading(true);
try {
await policyService.attachPolicy({
policyName: selectedPolicy,
userName: selectedUser,
});
onSuccess();
handleClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to attach policy');
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) {
onClose();
resetForm();
}
};
const resetForm = () => {
if (!initialPolicy) setSelectedPolicy('');
if (!initialUser) setSelectedUser('');
setError('');
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<form onSubmit={handleSubmit}>
<DialogTitle>Attach Policy to User</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{loadingData ? (
<Box textAlign="center" py={4}>
<Typography>Loading...</Typography>
</Box>
) : (
<>
<FormControl fullWidth margin="normal" disabled={!!initialPolicy}>
<InputLabel>Policy</InputLabel>
<Select
value={selectedPolicy}
onChange={(e) => setSelectedPolicy(e.target.value)}
label="Policy"
required
>
{policies.map((policy) => (
<MenuItem key={policy} value={policy}>
{policy}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth margin="normal" disabled={!!initialUser}>
<InputLabel>User</InputLabel>
<Select
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
label="User"
required
>
{users.map((user) => (
<MenuItem key={user} value={user}>
{user}
</MenuItem>
))}
</Select>
</FormControl>
<Alert severity="info" sx={{ mt: 2 }}>
This will grant the selected user all permissions defined in the policy.
</Alert>
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={loading || loadingData || !selectedPolicy || !selectedUser}
>
Attach Policy
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default AttachPolicyDialog;
@@ -0,0 +1,257 @@
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
Alert,
Tabs,
Tab,
Select,
MenuItem,
FormControl,
InputLabel,
Paper,
} from '@mui/material';
import policyService, { policyTemplates } from '../../services/policyService';
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`policy-tabpanel-${index}`}
aria-labelledby={`policy-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
interface CreatePolicyDialogProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
const CreatePolicyDialog: React.FC<CreatePolicyDialogProps> = ({
open,
onClose,
onSuccess,
}) => {
const [tabValue, setTabValue] = useState(0);
const [policyName, setPolicyName] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState('bucketFullAccess');
const [bucketName, setBucketName] = useState('');
const [customPolicy, setCustomPolicy] = useState('{\n "Version": "2012-10-17",\n "Statement": [\n \n ]\n}');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [validationError, setValidationError] = useState('');
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
setError('');
setValidationError('');
};
const handleTemplateChange = (event: any) => {
setSelectedTemplate(event.target.value);
generatePreview();
};
const generatePreview = () => {
if (bucketName && selectedTemplate) {
const generatedPolicy = policyService.generatePolicyFromTemplate(selectedTemplate, bucketName);
setCustomPolicy(generatedPolicy);
}
};
const handleBucketNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBucketName(e.target.value);
if (tabValue === 0 && e.target.value) {
generatePreview();
}
};
const validatePolicy = () => {
const validation = policyService.validatePolicyJSON(customPolicy);
if (!validation.valid) {
setValidationError(validation.error || 'Invalid policy');
return false;
}
setValidationError('');
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!policyName.trim()) {
setError('Policy name is required');
return;
}
let policyToCreate = customPolicy;
if (tabValue === 0) {
if (!bucketName.trim()) {
setError('Bucket name is required');
return;
}
policyToCreate = policyService.generatePolicyFromTemplate(selectedTemplate, bucketName);
}
if (!validatePolicy()) {
return;
}
setLoading(true);
try {
await policyService.createPolicy({
name: policyName,
policy: policyToCreate,
});
onSuccess();
handleClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create policy');
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) {
onClose();
resetForm();
}
};
const resetForm = () => {
setPolicyName('');
setSelectedTemplate('bucketFullAccess');
setBucketName('');
setCustomPolicy('{\n "Version": "2012-10-17",\n "Statement": [\n \n ]\n}');
setTabValue(0);
setError('');
setValidationError('');
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<form onSubmit={handleSubmit}>
<DialogTitle>Create New Policy</DialogTitle>
<DialogContent>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
autoFocus
margin="dense"
label="Policy Name"
fullWidth
value={policyName}
onChange={(e) => setPolicyName(e.target.value)}
required
disabled={loading}
sx={{ mb: 2 }}
helperText="Choose a descriptive name for your policy"
/>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab label="Use Template" />
<Tab label="Custom JSON" />
</Tabs>
</Box>
<TabPanel value={tabValue} index={0}>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel>Policy Template</InputLabel>
<Select
value={selectedTemplate}
onChange={handleTemplateChange}
label="Policy Template"
>
{Object.entries(policyTemplates).map(([key, template]) => (
<MenuItem key={key} value={key}>
{template.name} - {template.description}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
fullWidth
label="Bucket Name"
value={bucketName}
onChange={handleBucketNameChange}
required={tabValue === 0}
disabled={loading}
helperText="Enter the bucket name this policy will apply to"
sx={{ mb: 2 }}
/>
{bucketName && (
<Box>
<Typography variant="subtitle2" gutterBottom>
Policy Preview:
</Typography>
<Paper variant="outlined" sx={{ p: 2 }}>
<pre style={{ margin: 0, fontFamily: 'monospace', fontSize: '0.875rem' }}>
{customPolicy}
</pre>
</Paper>
</Box>
)}
</TabPanel>
<TabPanel value={tabValue} index={1}>
<TextField
fullWidth
multiline
rows={15}
label="Policy JSON"
value={customPolicy}
onChange={(e) => setCustomPolicy(e.target.value)}
error={!!validationError}
helperText={validationError || "Enter valid IAM policy JSON"}
sx={{ fontFamily: 'monospace' }}
onBlur={validatePolicy}
/>
</TabPanel>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={loading || !!validationError}
>
Create Policy
</Button>
</DialogActions>
</form>
</Dialog>
);
};
export default CreatePolicyDialog;
+336 -8
View File
@@ -1,15 +1,343 @@
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,
Tooltip,
Alert,
Snackbar,
Menu,
MenuItem,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import {
Add,
Delete,
Search,
Policy as PolicyIcon,
MoreVert,
AttachFile,
Code,
} from '@mui/icons-material';
import LoadingState from '../shared/LoadingState';
import EmptyState from '../shared/EmptyState';
import ConfirmDialog from '../shared/ConfirmDialog';
import CreatePolicyDialog from './CreatePolicyDialog';
import AttachPolicyDialog from './AttachPolicyDialog';
import policyService, { Policy } from '../../services/policyService';
const Policies: React.FC = () => {
const [policies, setPolicies] = useState<Policy[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [attachDialogOpen, setAttachDialogOpen] = useState(false);
const [policyToDelete, setPolicyToDelete] = useState<string | null>(null);
const [selectedPolicy, setSelectedPolicy] = useState<string | null>(null);
const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' as 'success' | 'error' });
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [viewPolicyDialog, setViewPolicyDialog] = useState<{ open: boolean; policy: Policy | null }>({ open: false, policy: null });
useEffect(() => {
loadPolicies();
}, []);
const loadPolicies = async () => {
try {
setLoading(true);
const policyList = await policyService.listPolicies();
setPolicies(policyList);
} catch (error) {
showSnackbar(error instanceof Error ? error.message : 'Failed to load policies', 'error');
} finally {
setLoading(false);
}
};
const handleCreatePolicy = () => {
setCreateDialogOpen(true);
};
const handleDeletePolicy = (policyName: string) => {
setPolicyToDelete(policyName);
setDeleteDialogOpen(true);
};
const confirmDeletePolicy = async () => {
if (!policyToDelete) return;
try {
await policyService.deletePolicy(policyToDelete);
showSnackbar('Policy deleted successfully', 'success');
await loadPolicies();
} catch (error) {
showSnackbar(error instanceof Error ? error.message : 'Failed to delete policy', 'error');
} finally {
setDeleteDialogOpen(false);
setPolicyToDelete(null);
}
};
const handleAttachPolicy = (policyName: string) => {
setSelectedPolicy(policyName);
setAttachDialogOpen(true);
};
const handleMenuClick = (event: React.MouseEvent<HTMLElement>, policyName: string) => {
setAnchorEl(event.currentTarget);
setSelectedPolicy(policyName);
};
const handleMenuClose = () => {
setAnchorEl(null);
setSelectedPolicy(null);
};
const handleViewPolicy = async () => {
if (!selectedPolicy) return;
const policy = policies.find(p => p.name === selectedPolicy);
if (policy) {
setViewPolicyDialog({ open: true, policy });
}
handleMenuClose();
};
const showSnackbar = (message: string, severity: 'success' | 'error') => {
setSnackbar({ open: true, message, severity });
};
const filteredPolicies = policies.filter((policy) =>
policy.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const isBuiltInPolicy = (policyName: string) => {
return policyName.startsWith('readonly') ||
policyName.startsWith('readwrite') ||
policyName.startsWith('diagnostics') ||
policyName.startsWith('writeonly') ||
policyName === 'consoleAdmin';
};
if (loading) {
return <LoadingState message="Loading policies..." />;
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Policies
</Typography>
<Typography variant="body1" color="text.secondary">
Policy management functionality coming soon...
</Typography>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h4">Policy Management</Typography>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleCreatePolicy}
>
Create Policy
</Button>
</Box>
<Paper>
<Box p={2}>
<TextField
fullWidth
variant="outlined"
placeholder="Search policies..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
</Box>
{filteredPolicies.length === 0 ? (
<EmptyState
icon={<PolicyIcon />}
title={searchTerm ? 'No policies found' : 'No policies yet'}
message={
searchTerm
? 'Try adjusting your search criteria'
: 'Create your first policy to manage access permissions'
}
actionLabel={!searchTerm ? 'Create Policy' : undefined}
onAction={!searchTerm ? handleCreatePolicy : undefined}
/>
) : (
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Policy Name</TableCell>
<TableCell>Type</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredPolicies.map((policy) => {
const isBuiltIn = isBuiltInPolicy(policy.name);
return (
<TableRow key={policy.name}>
<TableCell>
<Box display="flex" alignItems="center" gap={1}>
<PolicyIcon fontSize="small" color="action" />
<Typography variant="body2">{policy.name}</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={isBuiltIn ? 'Built-in' : 'Custom'}
color={isBuiltIn ? 'default' : 'primary'}
size="small"
variant={isBuiltIn ? 'filled' : 'outlined'}
/>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{policy.createdAt ? new Date(policy.createdAt).toLocaleDateString() : '-'}
</Typography>
</TableCell>
<TableCell align="right">
<Tooltip title="Attach to user">
<IconButton
onClick={() => handleAttachPolicy(policy.name)}
color="primary"
>
<AttachFile />
</IconButton>
</Tooltip>
<IconButton
onClick={(e) => handleMenuClick(e, policy.name)}
>
<MoreVert />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</Paper>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleViewPolicy}>
<Code sx={{ mr: 1 }} fontSize="small" />
View Policy
</MenuItem>
{selectedPolicy && !isBuiltInPolicy(selectedPolicy) && (
<MenuItem
onClick={() => {
if (selectedPolicy) {
handleDeletePolicy(selectedPolicy);
handleMenuClose();
}
}}
sx={{ color: 'error.main' }}
>
<Delete sx={{ mr: 1 }} fontSize="small" />
Delete Policy
</MenuItem>
)}
</Menu>
<CreatePolicyDialog
open={createDialogOpen}
onClose={() => setCreateDialogOpen(false)}
onSuccess={() => {
setCreateDialogOpen(false);
loadPolicies();
showSnackbar('Policy created successfully', 'success');
}}
/>
<AttachPolicyDialog
open={attachDialogOpen}
onClose={() => {
setAttachDialogOpen(false);
setSelectedPolicy(null);
}}
onSuccess={() => {
setAttachDialogOpen(false);
setSelectedPolicy(null);
showSnackbar('Policy attached successfully', 'success');
}}
initialPolicy={selectedPolicy || undefined}
/>
<ConfirmDialog
open={deleteDialogOpen}
title="Delete Policy"
message={`Are you sure you want to delete policy "${policyToDelete}"? This action cannot be undone.`}
confirmText="Delete"
confirmColor="error"
onConfirm={confirmDeletePolicy}
onCancel={() => {
setDeleteDialogOpen(false);
setPolicyToDelete(null);
}}
/>
<Dialog
open={viewPolicyDialog.open}
onClose={() => setViewPolicyDialog({ open: false, policy: null })}
maxWidth="md"
fullWidth
>
<DialogTitle>
Policy: {viewPolicyDialog.policy?.name}
</DialogTitle>
<DialogContent>
<Paper variant="outlined" sx={{ p: 2 }}>
<pre style={{ margin: 0, fontFamily: 'monospace', fontSize: '0.875rem', overflow: 'auto' }}>
{viewPolicyDialog.policy && JSON.stringify(JSON.parse(viewPolicyDialog.policy.policy), null, 2)}
</pre>
</Paper>
</DialogContent>
<DialogActions>
<Button onClick={() => setViewPolicyDialog({ open: false, policy: null })}>
Close
</Button>
</DialogActions>
</Dialog>
<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 './Policies';
@@ -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;
+259 -8
View File
@@ -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>
);
};
+1
View File
@@ -0,0 +1 @@
export { default } from './Users';
@@ -0,0 +1,57 @@
import React from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Button,
} from '@mui/material';
interface ConfirmDialogProps {
open: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
confirmColor?: 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success';
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
open,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
onConfirm,
onCancel,
confirmColor = 'primary',
}) => {
return (
<Dialog
open={open}
onClose={onCancel}
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
>
<DialogTitle id="confirm-dialog-title">{title}</DialogTitle>
<DialogContent>
<DialogContentText id="confirm-dialog-description">
{message}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="inherit">
{cancelText}
</Button>
<Button onClick={onConfirm} color={confirmColor} autoFocus>
{confirmText}
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmDialog;
@@ -0,0 +1,55 @@
import React from 'react';
import { Box, Typography, Button } from '@mui/material';
import { SvgIconComponent } from '@mui/icons-material';
interface EmptyStateProps {
icon?: React.ReactElement<SvgIconComponent>;
title: string;
message: string;
actionLabel?: string;
onAction?: () => void;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
message,
actionLabel,
onAction,
}) => {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="300px"
textAlign="center"
gap={2}
p={3}
>
{icon && (
<Box sx={{ fontSize: 64, color: 'text.secondary', opacity: 0.5 }}>
{icon}
</Box>
)}
<Typography variant="h6" gutterBottom>
{title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
{message}
</Typography>
{actionLabel && onAction && (
<Button
variant="contained"
onClick={onAction}
sx={{ mt: 2 }}
>
{actionLabel}
</Button>
)}
</Box>
);
};
export default EmptyState;
@@ -0,0 +1,30 @@
import React from 'react';
import { Box, CircularProgress, Typography } from '@mui/material';
interface LoadingStateProps {
message?: string;
size?: number;
}
const LoadingState: React.FC<LoadingStateProps> = ({
message = 'Loading...',
size = 40
}) => {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="200px"
gap={2}
>
<CircularProgress size={size} />
<Typography variant="body1" color="text.secondary">
{message}
</Typography>
</Box>
);
};
export default LoadingState;
+229
View File
@@ -0,0 +1,229 @@
import axios from 'axios';
import { getAuthHeaders } from '../utils/auth';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8080';
export interface Policy {
name: string;
policy: string;
type?: 'built-in' | 'custom';
createdAt?: string;
}
export interface CreatePolicyRequest {
name: string;
policy: string;
}
export interface AttachPolicyRequest {
policyName: string;
userName: string;
}
export interface PolicyListResponse {
policies: Policy[];
}
export interface PolicyTemplate {
name: string;
description: string;
generatePolicy: (bucketName: string) => object;
}
export const policyTemplates: Record<string, PolicyTemplate> = {
bucketFullAccess: {
name: 'Bucket Full Access',
description: 'Grants full access to a specific bucket',
generatePolicy: (bucketName: string) => ({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: ['s3:*'],
Resource: [
`arn:aws:s3:::${bucketName}`,
`arn:aws:s3:::${bucketName}/*`
],
},
],
}),
},
bucketReadOnly: {
name: 'Bucket Read Only',
description: 'Grants read-only access to a specific bucket',
generatePolicy: (bucketName: string) => ({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: [
's3:GetObject',
's3:ListBucket',
's3:GetBucketLocation',
],
Resource: [
`arn:aws:s3:::${bucketName}`,
`arn:aws:s3:::${bucketName}/*`
],
},
],
}),
},
bucketWriteOnly: {
name: 'Bucket Write Only',
description: 'Grants write-only access to a specific bucket',
generatePolicy: (bucketName: string) => ({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: [
's3:PutObject',
's3:DeleteObject',
's3:ListBucket',
],
Resource: [
`arn:aws:s3:::${bucketName}`,
`arn:aws:s3:::${bucketName}/*`
],
},
],
}),
},
};
class PolicyService {
private baseURL = `${API_BASE_URL}/api`;
async listPolicies(): Promise<Policy[]> {
try {
const response = await axios.get<PolicyListResponse>(`${this.baseURL}/policies`, {
headers: getAuthHeaders(),
});
return response.data.policies || [];
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to fetch policies');
}
throw error;
}
}
async createPolicy(policyData: CreatePolicyRequest): Promise<void> {
try {
await axios.post(`${this.baseURL}/policies`, policyData, {
headers: getAuthHeaders(),
});
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to create policy');
}
throw error;
}
}
async deletePolicy(policyName: string): Promise<void> {
try {
await axios.delete(`${this.baseURL}/policies/${policyName}`, {
headers: getAuthHeaders(),
});
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to delete policy');
}
throw error;
}
}
async getPolicy(policyName: string): Promise<Policy> {
try {
const response = await axios.get<Policy>(`${this.baseURL}/policies/${policyName}`, {
headers: getAuthHeaders(),
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to fetch policy');
}
throw error;
}
}
async attachPolicy(data: AttachPolicyRequest): Promise<void> {
try {
await axios.post(`${this.baseURL}/policies/attach`, data, {
headers: getAuthHeaders(),
});
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to attach policy');
}
throw error;
}
}
async detachPolicy(policyName: string, userName: string): Promise<void> {
try {
await axios.post(`${this.baseURL}/policies/detach`,
{ policyName, userName },
{
headers: getAuthHeaders(),
}
);
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to detach policy');
}
throw error;
}
}
// Policy validation helper
validatePolicyJSON(policyString: string): { valid: boolean; error?: string } {
try {
const policy = JSON.parse(policyString);
// Basic validation
if (!policy.Version) {
return { valid: false, error: 'Policy must have a Version field' };
}
if (!policy.Statement || !Array.isArray(policy.Statement)) {
return { valid: false, error: 'Policy must have a Statement array' };
}
if (policy.Statement.length === 0) {
return { valid: false, error: 'Policy must have at least one statement' };
}
// Validate each statement
for (let i = 0; i < policy.Statement.length; i++) {
const stmt = policy.Statement[i];
if (!stmt.Effect || !['Allow', 'Deny'].includes(stmt.Effect)) {
return { valid: false, error: `Statement ${i + 1}: Effect must be 'Allow' or 'Deny'` };
}
if (!stmt.Action) {
return { valid: false, error: `Statement ${i + 1}: Action is required` };
}
if (!stmt.Resource) {
return { valid: false, error: `Statement ${i + 1}: Resource is required` };
}
}
return { valid: true };
} catch (e) {
return { valid: false, error: 'Invalid JSON format' };
}
}
// Generate policy from template
generatePolicyFromTemplate(templateKey: string, bucketName: string): string {
const template = policyTemplates[templateKey];
if (!template) {
throw new Error('Invalid template');
}
const policy = template.generatePolicy(bucketName);
return JSON.stringify(policy, null, 2);
}
}
const policyService = new PolicyService();
export default policyService;
+121
View File
@@ -0,0 +1,121 @@
import axios from 'axios';
import { getAuthHeaders } from '../utils/auth';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8080';
export interface User {
accessKey: string;
status: 'enabled' | 'disabled';
policyName?: string;
memberOf?: string[];
createdAt?: string;
}
export interface CreateUserRequest {
accessKey: string;
secretKey: string;
}
export interface UserListResponse {
users: User[];
}
class UserService {
private baseURL = `${API_BASE_URL}/api`;
async listUsers(): Promise<User[]> {
try {
const response = await axios.get<UserListResponse>(`${this.baseURL}/users`, {
headers: getAuthHeaders(),
});
return response.data.users || [];
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to fetch users');
}
throw error;
}
}
async createUser(userData: CreateUserRequest): Promise<void> {
try {
await axios.post(`${this.baseURL}/users`, userData, {
headers: getAuthHeaders(),
});
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to create user');
}
throw error;
}
}
async deleteUser(accessKey: string): Promise<void> {
try {
await axios.delete(`${this.baseURL}/users/${accessKey}`, {
headers: getAuthHeaders(),
});
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to delete user');
}
throw error;
}
}
async updateUserStatus(accessKey: string, status: 'enabled' | 'disabled'): Promise<void> {
try {
await axios.put(`${this.baseURL}/users/${accessKey}/status`,
{ status },
{
headers: getAuthHeaders(),
}
);
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to update user status');
}
throw error;
}
}
async getUserPolicies(accessKey: string): Promise<string[]> {
try {
const response = await axios.get<{ policies: string[] }>(`${this.baseURL}/users/${accessKey}/policies`, {
headers: getAuthHeaders(),
});
return response.data.policies || [];
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(error.response?.data?.error || 'Failed to fetch user policies');
}
throw error;
}
}
// Password validation helper
validatePassword(password: string): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain at least one number');
}
return {
valid: errors.length === 0,
errors,
};
}
}
const userService = new UserService();
export default userService;
+25
View File
@@ -0,0 +1,25 @@
export const getAuthHeaders = (): Record<string, string> => {
const token = localStorage.getItem('authToken');
if (!token) {
return {};
}
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
};
};
export const isAuthenticated = (): boolean => {
const token = localStorage.getItem('authToken');
return !!token;
};
export const setAuthToken = (token: string): void => {
localStorage.setItem('authToken', token);
};
export const clearAuthToken = (): void => {
localStorage.removeItem('authToken');
};