From 55983d0088e9eae04485dacaaba438c7f85b50c6 Mon Sep 17 00:00:00 2001 From: paul Date: Wed, 23 Jul 2025 16:45:39 +0200 Subject: [PATCH] feat: Implement user and policy management UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Policies/AttachPolicyDialog.tsx | 182 +++++++++ .../Policies/CreatePolicyDialog.tsx | 257 +++++++++++++ frontend/src/components/Policies/Policies.tsx | 344 +++++++++++++++++- frontend/src/components/Policies/index.tsx | 1 + .../src/components/Users/CreateUserDialog.tsx | 265 ++++++++++++++ frontend/src/components/Users/Users.tsx | 267 +++++++++++++- frontend/src/components/Users/index.tsx | 1 + .../src/components/shared/ConfirmDialog.tsx | 57 +++ frontend/src/components/shared/EmptyState.tsx | 55 +++ .../src/components/shared/LoadingState.tsx | 30 ++ frontend/src/services/policyService.ts | 229 ++++++++++++ frontend/src/services/userService.ts | 121 ++++++ frontend/src/utils/auth.ts | 25 ++ 13 files changed, 1818 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/Policies/AttachPolicyDialog.tsx create mode 100644 frontend/src/components/Policies/CreatePolicyDialog.tsx create mode 100644 frontend/src/components/Policies/index.tsx create mode 100644 frontend/src/components/Users/CreateUserDialog.tsx create mode 100644 frontend/src/components/Users/index.tsx create mode 100644 frontend/src/components/shared/ConfirmDialog.tsx create mode 100644 frontend/src/components/shared/EmptyState.tsx create mode 100644 frontend/src/components/shared/LoadingState.tsx create mode 100644 frontend/src/services/policyService.ts create mode 100644 frontend/src/services/userService.ts create mode 100644 frontend/src/utils/auth.ts diff --git a/frontend/src/components/Policies/AttachPolicyDialog.tsx b/frontend/src/components/Policies/AttachPolicyDialog.tsx new file mode 100644 index 0000000..a7bb058 --- /dev/null +++ b/frontend/src/components/Policies/AttachPolicyDialog.tsx @@ -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 = ({ + open, + onClose, + onSuccess, + initialPolicy, + initialUser, +}) => { + const [selectedPolicy, setSelectedPolicy] = useState(initialPolicy || ''); + const [selectedUser, setSelectedUser] = useState(initialUser || ''); + const [policies, setPolicies] = useState([]); + const [users, setUsers] = useState([]); + 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 ( + +
+ Attach Policy to User + + {error && ( + + {error} + + )} + + {loadingData ? ( + + Loading... + + ) : ( + <> + + Policy + + + + + User + + + + + This will grant the selected user all permissions defined in the policy. + + + )} + + + + + +
+
+ ); +}; + +export default AttachPolicyDialog; \ No newline at end of file diff --git a/frontend/src/components/Policies/CreatePolicyDialog.tsx b/frontend/src/components/Policies/CreatePolicyDialog.tsx new file mode 100644 index 0000000..bd7342b --- /dev/null +++ b/frontend/src/components/Policies/CreatePolicyDialog.tsx @@ -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 ( + + ); +} + +interface CreatePolicyDialogProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; +} + +const CreatePolicyDialog: React.FC = ({ + 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) => { + 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 ( + +
+ Create New Policy + + {error && ( + + {error} + + )} + + setPolicyName(e.target.value)} + required + disabled={loading} + sx={{ mb: 2 }} + helperText="Choose a descriptive name for your policy" + /> + + + + + + + + + + + Policy Template + + + + + + {bucketName && ( + + + Policy Preview: + + +
+                    {customPolicy}
+                  
+
+
+ )} +
+ + + setCustomPolicy(e.target.value)} + error={!!validationError} + helperText={validationError || "Enter valid IAM policy JSON"} + sx={{ fontFamily: 'monospace' }} + onBlur={validatePolicy} + /> + +
+ + + + +
+
+ ); +}; + +export default CreatePolicyDialog; \ No newline at end of file diff --git a/frontend/src/components/Policies/Policies.tsx b/frontend/src/components/Policies/Policies.tsx index 7063721..249deb8 100644 --- a/frontend/src/components/Policies/Policies.tsx +++ b/frontend/src/components/Policies/Policies.tsx @@ -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([]); + 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(null); + const [selectedPolicy, setSelectedPolicy] = useState(null); + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' as 'success' | 'error' }); + const [anchorEl, setAnchorEl] = useState(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, 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 ; + } + return ( - - Policies - - - Policy management functionality coming soon... - + + Policy Management + + + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {filteredPolicies.length === 0 ? ( + } + 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} + /> + ) : ( + + + + + Policy Name + Type + Created + Actions + + + + {filteredPolicies.map((policy) => { + const isBuiltIn = isBuiltInPolicy(policy.name); + return ( + + + + + {policy.name} + + + + + + + + {policy.createdAt ? new Date(policy.createdAt).toLocaleDateString() : '-'} + + + + + handleAttachPolicy(policy.name)} + color="primary" + > + + + + handleMenuClick(e, policy.name)} + > + + + + + ); + })} + +
+
+ )} +
+ + + + + View Policy + + {selectedPolicy && !isBuiltInPolicy(selectedPolicy) && ( + { + if (selectedPolicy) { + handleDeletePolicy(selectedPolicy); + handleMenuClose(); + } + }} + sx={{ color: 'error.main' }} + > + + Delete Policy + + )} + + + setCreateDialogOpen(false)} + onSuccess={() => { + setCreateDialogOpen(false); + loadPolicies(); + showSnackbar('Policy created successfully', 'success'); + }} + /> + + { + setAttachDialogOpen(false); + setSelectedPolicy(null); + }} + onSuccess={() => { + setAttachDialogOpen(false); + setSelectedPolicy(null); + showSnackbar('Policy attached successfully', 'success'); + }} + initialPolicy={selectedPolicy || undefined} + /> + + { + setDeleteDialogOpen(false); + setPolicyToDelete(null); + }} + /> + + setViewPolicyDialog({ open: false, policy: null })} + maxWidth="md" + fullWidth + > + + Policy: {viewPolicyDialog.policy?.name} + + + +
+              {viewPolicyDialog.policy && JSON.stringify(JSON.parse(viewPolicyDialog.policy.policy), null, 2)}
+            
+
+
+ + + +
+ + setSnackbar({ ...snackbar, open: false })} + > + setSnackbar({ ...snackbar, open: false })} + severity={snackbar.severity} + sx={{ width: '100%' }} + > + {snackbar.message} + +
); }; diff --git a/frontend/src/components/Policies/index.tsx b/frontend/src/components/Policies/index.tsx new file mode 100644 index 0000000..307593c --- /dev/null +++ b/frontend/src/components/Policies/index.tsx @@ -0,0 +1 @@ +export { default } from './Policies'; \ No newline at end of file diff --git a/frontend/src/components/Users/CreateUserDialog.tsx b/frontend/src/components/Users/CreateUserDialog.tsx new file mode 100644 index 0000000..c96b3e7 --- /dev/null +++ b/frontend/src/components/Users/CreateUserDialog.tsx @@ -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 = ({ + 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 ? ( + + ) : ( + + ); + }; + + return ( + +
+ + {showSuccess ? 'User Created Successfully' : 'Create New User'} + + + {error && ( + + {error} + + )} + + {!showSuccess ? ( + <> + setUsername(e.target.value)} + required + disabled={loading} + sx={{ mb: 2 }} + /> + + setPassword(e.target.value)} + required + disabled={loading} + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? : } + + + ), + }} + /> + + {password && ( + + + Password Requirements: + + + + + {getValidationIcon(password.length >= 8)} + + = 8 ? 'text.primary' : 'text.secondary', + }} + /> + + + + {getValidationIcon(/[A-Z]/.test(password))} + + + + + + {getValidationIcon(/[a-z]/.test(password))} + + + + + + {getValidationIcon(/[0-9]/.test(password))} + + + + + + )} + + ) : ( + + + User created successfully! Save these credentials securely - the password cannot be retrieved later. + + + + Username: {username} +
+ Password: {password} +
+
+ + + +
+ )} +
+ + {!showSuccess ? ( + <> + + + + ) : ( + + )} + +
+
+ ); +}; + +export default CreateUserDialog; \ No newline at end of file diff --git a/frontend/src/components/Users/Users.tsx b/frontend/src/components/Users/Users.tsx index 03fecf2..3ab16d9 100644 --- a/frontend/src/components/Users/Users.tsx +++ b/frontend/src/components/Users/Users.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [userToDelete, setUserToDelete] = useState(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 ; + } + return ( - - Users - - - User management functionality coming soon... - + + User Management + + + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {filteredUsers.length === 0 ? ( + } + 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} + /> + ) : ( + + + + + Username + Status + Policy + Groups + Actions + + + + {filteredUsers.map((user) => ( + + + + + {user.accessKey} + + + + + + + {user.policyName ? ( + + ) : ( + + No policy + + )} + + + {user.memberOf && user.memberOf.length > 0 ? ( + user.memberOf.map((group) => ( + + )) + ) : ( + + No groups + + )} + + + + handleToggleUserStatus(user.accessKey, user.status)} + color="primary" + /> + + + handleDeleteUser(user.accessKey)} + color="error" + > + + + + + + ))} + +
+
+ )} +
+ + setCreateDialogOpen(false)} + onSuccess={() => { + setCreateDialogOpen(false); + loadUsers(); + showSnackbar('User created successfully', 'success'); + }} + /> + + { + setDeleteDialogOpen(false); + setUserToDelete(null); + }} + /> + + setSnackbar({ ...snackbar, open: false })} + > + setSnackbar({ ...snackbar, open: false })} + severity={snackbar.severity} + sx={{ width: '100%' }} + > + {snackbar.message} + +
); }; diff --git a/frontend/src/components/Users/index.tsx b/frontend/src/components/Users/index.tsx new file mode 100644 index 0000000..36a5e4e --- /dev/null +++ b/frontend/src/components/Users/index.tsx @@ -0,0 +1 @@ +export { default } from './Users'; \ No newline at end of file diff --git a/frontend/src/components/shared/ConfirmDialog.tsx b/frontend/src/components/shared/ConfirmDialog.tsx new file mode 100644 index 0000000..afefcfc --- /dev/null +++ b/frontend/src/components/shared/ConfirmDialog.tsx @@ -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 = ({ + open, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + onConfirm, + onCancel, + confirmColor = 'primary', +}) => { + return ( + + {title} + + + {message} + + + + + + + + ); +}; + +export default ConfirmDialog; \ No newline at end of file diff --git a/frontend/src/components/shared/EmptyState.tsx b/frontend/src/components/shared/EmptyState.tsx new file mode 100644 index 0000000..301b4d6 --- /dev/null +++ b/frontend/src/components/shared/EmptyState.tsx @@ -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; + title: string; + message: string; + actionLabel?: string; + onAction?: () => void; +} + +const EmptyState: React.FC = ({ + icon, + title, + message, + actionLabel, + onAction, +}) => { + return ( + + {icon && ( + + {icon} + + )} + + {title} + + + {message} + + {actionLabel && onAction && ( + + )} + + ); +}; + +export default EmptyState; \ No newline at end of file diff --git a/frontend/src/components/shared/LoadingState.tsx b/frontend/src/components/shared/LoadingState.tsx new file mode 100644 index 0000000..d7825ec --- /dev/null +++ b/frontend/src/components/shared/LoadingState.tsx @@ -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 = ({ + message = 'Loading...', + size = 40 +}) => { + return ( + + + + {message} + + + ); +}; + +export default LoadingState; \ No newline at end of file diff --git a/frontend/src/services/policyService.ts b/frontend/src/services/policyService.ts new file mode 100644 index 0000000..ecf461f --- /dev/null +++ b/frontend/src/services/policyService.ts @@ -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 = { + 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 { + try { + const response = await axios.get(`${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 { + 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 { + 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 { + try { + const response = await axios.get(`${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 { + 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 { + 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; \ No newline at end of file diff --git a/frontend/src/services/userService.ts b/frontend/src/services/userService.ts new file mode 100644 index 0000000..3cf7454 --- /dev/null +++ b/frontend/src/services/userService.ts @@ -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 { + try { + const response = await axios.get(`${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 { + 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 { + 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 { + 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 { + 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; \ No newline at end of file diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts new file mode 100644 index 0000000..a0199a3 --- /dev/null +++ b/frontend/src/utils/auth.ts @@ -0,0 +1,25 @@ +export const getAuthHeaders = (): Record => { + 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'); +}; \ No newline at end of file