diff --git a/frontend/src/components/AccessManagement.css b/frontend/src/components/AccessManagement.css new file mode 100644 index 0000000..25a1dfb --- /dev/null +++ b/frontend/src/components/AccessManagement.css @@ -0,0 +1,100 @@ +.access-management { + margin-top: 1.5rem; +} + +.access-management__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.access-management__header h3 { + margin: 0; +} + +.access-management__form { + background: var(--bg-tertiary); + padding: 1rem; + border-radius: 6px; + margin-bottom: 1rem; +} + +.access-management__form .form-row { + display: flex; + gap: 1rem; + align-items: flex-end; +} + +.access-management__form .form-group { + flex: 1; +} + +.access-management__form .form-group:last-of-type { + flex: 0 0 auto; +} + +.access-management__list { + margin-top: 1rem; +} + +.access-table { + width: 100%; + border-collapse: collapse; +} + +.access-table th, +.access-table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.access-table th { + font-weight: 600; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.access-table td.actions { + display: flex; + gap: 0.5rem; +} + +.access-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: capitalize; +} + +.access-badge--read { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.access-badge--write { + background: var(--color-info-bg); + color: var(--color-info); +} + +.access-badge--admin { + background: var(--color-success-bg); + color: var(--color-success); +} + +.btn-sm { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.btn-danger { + background: var(--color-error); + color: white; +} + +.btn-danger:hover { + background: #c0392b; +} diff --git a/frontend/src/components/AccessManagement.tsx b/frontend/src/components/AccessManagement.tsx new file mode 100644 index 0000000..5c71f88 --- /dev/null +++ b/frontend/src/components/AccessManagement.tsx @@ -0,0 +1,248 @@ +import { useState, useEffect, useCallback } from 'react'; +import { AccessPermission, AccessLevel } from '../types'; +import { + listProjectPermissions, + grantProjectAccess, + updateProjectAccess, + revokeProjectAccess, +} from '../api'; +import './AccessManagement.css'; + +interface AccessManagementProps { + projectName: string; +} + +export function AccessManagement({ projectName }: AccessManagementProps) { + const [permissions, setPermissions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Form state + const [showAddForm, setShowAddForm] = useState(false); + const [newUsername, setNewUsername] = useState(''); + const [newLevel, setNewLevel] = useState('read'); + const [submitting, setSubmitting] = useState(false); + + // Edit state + const [editingUser, setEditingUser] = useState(null); + const [editLevel, setEditLevel] = useState('read'); + + const loadPermissions = useCallback(async () => { + try { + setLoading(true); + const data = await listProjectPermissions(projectName); + setPermissions(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load permissions'); + } finally { + setLoading(false); + } + }, [projectName]); + + useEffect(() => { + loadPermissions(); + }, [loadPermissions]); + + const handleGrant = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newUsername.trim()) return; + + try { + setSubmitting(true); + setError(null); + await grantProjectAccess(projectName, { + username: newUsername.trim(), + level: newLevel, + }); + setSuccess(`Access granted to ${newUsername}`); + setNewUsername(''); + setNewLevel('read'); + setShowAddForm(false); + await loadPermissions(); + setTimeout(() => setSuccess(null), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to grant access'); + } finally { + setSubmitting(false); + } + }; + + const handleUpdate = async (username: string) => { + try { + setSubmitting(true); + setError(null); + await updateProjectAccess(projectName, username, { level: editLevel }); + setSuccess(`Updated access for ${username}`); + setEditingUser(null); + await loadPermissions(); + setTimeout(() => setSuccess(null), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update access'); + } finally { + setSubmitting(false); + } + }; + + const handleRevoke = async (username: string) => { + if (!confirm(`Revoke access for ${username}?`)) return; + + try { + setSubmitting(true); + setError(null); + await revokeProjectAccess(projectName, username); + setSuccess(`Access revoked for ${username}`); + await loadPermissions(); + setTimeout(() => setSuccess(null), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to revoke access'); + } finally { + setSubmitting(false); + } + }; + + const startEdit = (permission: AccessPermission) => { + setEditingUser(permission.user_id); + setEditLevel(permission.level as AccessLevel); + }; + + const cancelEdit = () => { + setEditingUser(null); + }; + + if (loading) { + return
Loading permissions...
; + } + + return ( +
+
+

Access Management

+ +
+ + {error &&
{error}
} + {success &&
{success}
} + + {showAddForm && ( +
+
+
+ + setNewUsername(e.target.value)} + placeholder="Enter username" + required + disabled={submitting} + /> +
+
+ + +
+ +
+
+ )} + +
+ {permissions.length === 0 ? ( +

No explicit permissions set. Only the project owner has access.

+ ) : ( + + + + + + + + + + + {permissions.map((p) => ( + + + + + + + ))} + +
UserAccess LevelGrantedActions
{p.user_id} + {editingUser === p.user_id ? ( + + ) : ( + + {p.level} + + )} + {new Date(p.created_at).toLocaleDateString()} + {editingUser === p.user_id ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx index 46d8832..876e776 100644 --- a/frontend/src/pages/ProjectPage.tsx +++ b/frontend/src/pages/ProjectPage.tsx @@ -8,6 +8,7 @@ import { SearchInput } from '../components/SearchInput'; import { SortDropdown, SortOption } from '../components/SortDropdown'; import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { Pagination } from '../components/Pagination'; +import { AccessManagement } from '../components/AccessManagement'; import { useAuth } from '../contexts/AuthContext'; import './Home.css'; @@ -45,6 +46,7 @@ function ProjectPage() { // Derived permissions const canWrite = accessLevel === 'write' || accessLevel === 'admin'; + const canAdmin = accessLevel === 'admin'; // Get params from URL const page = parseInt(searchParams.get('page') || '1', 10); @@ -337,6 +339,10 @@ function ProjectPage() { )} )} + + {canAdmin && projectName && ( + + )} ); }