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:
@@ -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;
|
||||||
@@ -1,15 +1,343 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Box, Typography } from '@mui/material';
|
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: 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||||
Policies
|
<Typography variant="h4">Policy Management</Typography>
|
||||||
</Typography>
|
<Button
|
||||||
<Typography variant="body1" color="text.secondary">
|
variant="contained"
|
||||||
Policy management functionality coming soon...
|
startIcon={<Add />}
|
||||||
</Typography>
|
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>
|
</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;
|
||||||
@@ -1,15 +1,266 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Box, Typography } from '@mui/material';
|
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: 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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h4" gutterBottom>
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||||
Users
|
<Typography variant="h4">User Management</Typography>
|
||||||
</Typography>
|
<Button
|
||||||
<Typography variant="body1" color="text.secondary">
|
variant="contained"
|
||||||
User management functionality coming soon...
|
startIcon={<PersonAdd />}
|
||||||
</Typography>
|
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>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user