Skip to content

Commit f0af023

Browse files
Merge pull request #148 from bg-playground/copilot/add-role-gate-component
feat: Enforce RBAC in frontend UI with RoleGate component and useRoleGate hook
2 parents 5bc120f + b734fe4 commit f0af023

7 files changed

Lines changed: 155 additions & 83 deletions

File tree

frontend/src/components/Navigation.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ export const Navigation: React.FC = () => {
1717
];
1818

1919
const roleBadgeColor = {
20-
admin: 'bg-red-500',
21-
reviewer: 'bg-yellow-500',
22-
viewer: 'bg-blue-500',
20+
admin: 'bg-purple-600',
21+
reviewer: 'bg-blue-500',
22+
viewer: 'bg-gray-400',
2323
} as const;
2424

2525
return (
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react';
2+
import { useRoleGate } from '../hooks/useRoleGate';
3+
4+
interface RoleGateProps {
5+
allowedRoles: string[];
6+
children: React.ReactNode;
7+
fallback?: React.ReactNode;
8+
}
9+
10+
export const RoleGate: React.FC<RoleGateProps> = ({ allowedRoles, children, fallback = null }) => {
11+
const { hasRole } = useRoleGate();
12+
return hasRole(allowedRoles) ? <>{children}</> : <>{fallback}</>;
13+
};

frontend/src/components/SuggestionCard.tsx

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { SuggestionStatus } from '../types/api';
33
import type { Suggestion, Requirement, TestCase } from '../types/api';
4+
import { useRoleGate } from '../hooks/useRoleGate';
45

56
interface SuggestionCardProps {
67
suggestion: Suggestion;
@@ -31,18 +32,22 @@ export const SuggestionCard = React.forwardRef<HTMLDivElement, SuggestionCardPro
3132
onPreview,
3233
}, ref) => {
3334
const badge = getConfidenceBadge(suggestion.similarity_score);
35+
const { hasRole } = useRoleGate();
36+
const canReview = hasRole(['admin', 'reviewer']);
3437
return (
3538
<div
3639
ref={ref}
3740
className={`bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow${isFocused ? ' ring-2 ring-blue-500' : ''}`}
3841
>
3942
<div className="flex items-start gap-3">
40-
<input
41-
type="checkbox"
42-
checked={isSelected}
43-
onChange={(e) => onToggleSelect(suggestion.id, e.target.checked)}
44-
className="mt-1"
45-
/>
43+
{canReview && (
44+
<input
45+
type="checkbox"
46+
checked={isSelected}
47+
onChange={(e) => onToggleSelect(suggestion.id, e.target.checked)}
48+
className="mt-1"
49+
/>
50+
)}
4651
<div className="flex-1">
4752
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
4853
<div className="border-r pr-6">
@@ -103,18 +108,22 @@ export const SuggestionCard = React.forwardRef<HTMLDivElement, SuggestionCardPro
103108
>
104109
Quick Preview
105110
</button>
106-
<button
107-
onClick={() => onReview(suggestion.id, SuggestionStatus.REJECTED)}
108-
className="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
109-
>
110-
Reject
111-
</button>
112-
<button
113-
onClick={() => onReview(suggestion.id, SuggestionStatus.ACCEPTED)}
114-
className="px-6 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
115-
>
116-
Accept
117-
</button>
111+
{canReview && (
112+
<>
113+
<button
114+
onClick={() => onReview(suggestion.id, SuggestionStatus.REJECTED)}
115+
className="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
116+
>
117+
Reject
118+
</button>
119+
<button
120+
onClick={() => onReview(suggestion.id, SuggestionStatus.ACCEPTED)}
121+
className="px-6 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
122+
>
123+
Accept
124+
</button>
125+
</>
126+
)}
118127
</div>
119128
</div>
120129
</div>

frontend/src/hooks/useRoleGate.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useAuth } from '../context/AuthContext';
2+
3+
export interface UseRoleGateResult {
4+
role: string;
5+
isAdmin: boolean;
6+
isReviewer: boolean;
7+
isViewer: boolean;
8+
hasRole: (roles: string[]) => boolean;
9+
}
10+
11+
export function useRoleGate(): UseRoleGateResult {
12+
const { user } = useAuth();
13+
const role = user?.role ?? 'viewer';
14+
15+
return {
16+
role,
17+
isAdmin: role === 'admin',
18+
isReviewer: role === 'reviewer',
19+
isViewer: role === 'viewer',
20+
hasRole: (roles: string[]) => roles.includes(role),
21+
};
22+
}

frontend/src/pages/RequirementsPage.tsx

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Requirement, RequirementCreate, RequirementUpdate } from '../types
55
import { LoadingSpinner } from '../components/LoadingSpinner';
66
import { Pagination } from '../components/Pagination';
77
import { useToast } from '../context/ToastContext';
8+
import { useRoleGate } from '../hooks/useRoleGate';
89

910
export const RequirementsPage: React.FC = () => {
1011
const [requirements, setRequirements] = useState<Requirement[]>([]);
@@ -23,6 +24,7 @@ export const RequirementsPage: React.FC = () => {
2324
status: RequirementStatus.DRAFT,
2425
});
2526
const { showToast } = useToast();
27+
const { isAdmin } = useRoleGate();
2628

2729
const loadRequirements = useCallback(async (targetPage = page) => {
2830
try {
@@ -120,12 +122,14 @@ export const RequirementsPage: React.FC = () => {
120122
<h1 className="text-3xl font-bold text-gray-900">
121123
Requirements {total > 0 && <span className="text-lg font-normal text-gray-500">({total} total)</span>}
122124
</h1>
123-
<button
124-
onClick={() => setShowModal(true)}
125-
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
126-
>
127-
+ New Requirement
128-
</button>
125+
{isAdmin && (
126+
<button
127+
onClick={() => setShowModal(true)}
128+
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
129+
>
130+
+ New Requirement
131+
</button>
132+
)}
129133
</div>
130134

131135
{requirements.length === 0 ? (
@@ -166,18 +170,22 @@ export const RequirementsPage: React.FC = () => {
166170
</div>
167171
</div>
168172
<div className="flex gap-2 ml-4">
169-
<button
170-
onClick={() => handleEdit(req)}
171-
className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600"
172-
>
173-
Edit
174-
</button>
175-
<button
176-
onClick={() => handleDelete(req.id)}
177-
className="px-4 py-2 text-sm bg-red-500 text-white rounded hover:bg-red-600"
178-
>
179-
Delete
180-
</button>
173+
{isAdmin && (
174+
<>
175+
<button
176+
onClick={() => handleEdit(req)}
177+
className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600"
178+
>
179+
Edit
180+
</button>
181+
<button
182+
onClick={() => handleDelete(req.id)}
183+
className="px-4 py-2 text-sm bg-red-500 text-white rounded hover:bg-red-600"
184+
>
185+
Delete
186+
</button>
187+
</>
188+
)}
181189
</div>
182190
</div>
183191
</div>
@@ -187,7 +195,7 @@ export const RequirementsPage: React.FC = () => {
187195

188196
<Pagination page={page} pages={pages} total={total} onPageChange={setPage} />
189197

190-
{showModal && (
198+
{showModal && isAdmin && (
191199
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
192200
<div className="bg-white rounded-lg p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
193201
<h2 className="text-2xl font-bold mb-6">

frontend/src/pages/SuggestionDashboard.tsx

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { SuggestionStats } from '../components/SuggestionStats';
1616
import { SuggestionCard } from '../components/SuggestionCard';
1717
import { SuggestionPreviewModal } from '../components/SuggestionPreviewModal';
1818
import { VirtualizedSuggestionList } from '../components/VirtualizedSuggestionList';
19+
import { useRoleGate } from '../hooks/useRoleGate';
1920

2021
const VIRTUALIZATION_THRESHOLD = 50;
2122

@@ -100,6 +101,8 @@ export const SuggestionDashboard: React.FC = () => {
100101
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
101102
const [previewSuggestion, setPreviewSuggestion] = useState<Suggestion | null>(null);
102103
const { showToast } = useToast();
104+
const { isAdmin, hasRole } = useRoleGate();
105+
const canReview = hasRole(['admin', 'reviewer']);
103106

104107
// Filters state: priority URL params > localStorage > defaults
105108
const [filters, setFilters] = useState<Filters>(() =>
@@ -273,6 +276,7 @@ export const SuggestionDashboard: React.FC = () => {
273276
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev));
274277
break;
275278
case 'a': {
279+
if (!canReview) break;
276280
const hasFocusedCard = selectedIds.size === 0 && focusedIndex >= 0;
277281
if (hasFocusedCard) {
278282
// Accept focused card
@@ -284,6 +288,7 @@ export const SuggestionDashboard: React.FC = () => {
284288
break;
285289
}
286290
case 'r': {
291+
if (!canReview) break;
287292
const hasFocusedCard = selectedIds.size === 0 && focusedIndex >= 0;
288293
if (hasFocusedCard) {
289294
handleReview(suggestions[focusedIndex].id, SuggestionStatus.REJECTED);
@@ -297,6 +302,7 @@ export const SuggestionDashboard: React.FC = () => {
297302
case ' ':
298303
if (focusedIndex >= 0) {
299304
e.preventDefault();
305+
if (!canReview) break;
300306
const focusedId = suggestions[focusedIndex].id;
301307
setSelectedIds((prev) => {
302308
const next = new Set(prev);
@@ -313,15 +319,15 @@ export const SuggestionDashboard: React.FC = () => {
313319
if (focusedIndex >= 0 && selectedIds.size === 0) {
314320
// Open preview for focused suggestion
315321
setPreviewSuggestion(suggestions[focusedIndex]);
316-
} else if (selectedIds.size > 0) {
322+
} else if (selectedIds.size > 0 && canReview) {
317323
// Accept selected
318324
handleBulkAccept();
319325
}
320326
break;
321327
case 'Delete':
322328
case 'Backspace':
323329
// Reject selected
324-
if (selectedIds.size > 0 && e.shiftKey) {
330+
if (selectedIds.size > 0 && e.shiftKey && canReview) {
325331
e.preventDefault();
326332
handleBulkReject();
327333
}
@@ -331,7 +337,7 @@ export const SuggestionDashboard: React.FC = () => {
331337

332338
window.addEventListener('keydown', handleKeyPress);
333339
return () => window.removeEventListener('keydown', handleKeyPress);
334-
}, [suggestions, selectedIds, focusedIndex, handleBulkAccept, handleBulkReject, handleReview]);
340+
}, [suggestions, selectedIds, focusedIndex, canReview, handleBulkAccept, handleBulkReject, handleReview]);
335341

336342
const handleToggleSelect = useCallback((id: string, checked: boolean) => {
337343
setSelectedIds((prev) => {
@@ -357,27 +363,33 @@ export const SuggestionDashboard: React.FC = () => {
357363
<div className="container mx-auto px-4 py-8">
358364
<div className="flex items-center justify-between mb-8">
359365
<h1 className="text-3xl font-bold text-gray-900">AI Suggestion Dashboard</h1>
360-
<button
361-
onClick={handleGenerate}
362-
disabled={generating}
363-
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
364-
>
365-
{generating ? (
366-
<>
367-
<LoadingSpinner size="sm" />
368-
Generating...
369-
</>
370-
) : (
371-
'Generate Suggestions'
366+
<div className="flex items-center gap-3">
367+
{isAdmin && (
368+
<button
369+
onClick={handleGenerate}
370+
disabled={generating}
371+
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
372+
>
373+
{generating ? (
374+
<>
375+
<LoadingSpinner size="sm" />
376+
Generating...
377+
</>
378+
) : (
379+
'Generate Suggestions'
380+
)}
381+
</button>
382+
)}
383+
{canReview && (
384+
<button
385+
onClick={handleExportCsv}
386+
disabled={exporting}
387+
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400"
388+
>
389+
Export CSV
390+
</button>
372391
)}
373-
</button>
374-
<button
375-
onClick={handleExportCsv}
376-
disabled={exporting}
377-
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400"
378-
>
379-
Export CSV
380-
</button>
392+
</div>
381393
</div>
382394

383395
<KeyboardShortcutsHelp />
@@ -425,7 +437,7 @@ export const SuggestionDashboard: React.FC = () => {
425437
)}
426438

427439
{/* Bulk action bar */}
428-
{selectedIds.size > 0 && (
440+
{selectedIds.size > 0 && canReview && (
429441
<div className="fixed bottom-0 left-0 right-0 bg-primary-600 text-white shadow-lg p-4 flex items-center justify-between z-50">
430442
<div>
431443
<span className="font-semibold">{selectedIds.size} suggestions selected</span>

0 commit comments

Comments
 (0)