Skip to content

Commit fe3a88b

Browse files
Merge pull request #403 from Ibinola/feat/user-role-management
feat(ui): implement admin user and role management
2 parents 180064e + 075eff2 commit fe3a88b

File tree

7 files changed

+466
-16
lines changed

7 files changed

+466
-16
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import axios from 'axios';
2+
import { User, Role } from '../types/admin';
3+
4+
const api = axios.create({ baseURL: '/api' });
5+
6+
export const AdminService = {
7+
getUsers: () => api.get<User[]>('/users'),
8+
createUser: (data: Partial<User>) => api.post('/users', data),
9+
updateUser: (id: string, data: Partial<User>) =>
10+
api.put(`/users/${id}`, data),
11+
deleteUser: (id: string) => api.delete(`/users/${id}`),
12+
getRoles: () => api.get<Role[]>('/roles'),
13+
updatePermissions: (roleId: string, permissions: string[]) =>
14+
api.put(`/roles/${roleId}/permissions`, { permissions }),
15+
};

backend/src/types/admin.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export type User = {
2+
id: string;
3+
name: string;
4+
email: string;
5+
role: string;
6+
department: string;
7+
status: 'active' | 'inactive';
8+
lastActive: string;
9+
};
10+
11+
export type Role = {
12+
id: string;
13+
name: string;
14+
description: string;
15+
isSystem?: boolean; // Cannot delete
16+
};
17+
18+
export type Permission = {
19+
id: string;
20+
category: 'Assets' | 'Departments' | 'Reports' | 'Users' | 'Settings';
21+
action: string; // e.g., 'view', 'create'
22+
name: string; // Display name
23+
};
24+
25+
export type RolePermission = Record<string, string[]>; // roleId -> permissionIds[]
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import React from 'react';
2+
import { Role, Permission } from '../../../backend/src/types/admin'
3+
import { Check, X } from 'lucide-react';
4+
5+
interface RoleMatrixProps {
6+
roles: Role[];
7+
permissions: Permission[];
8+
rolePermissions: Record<string, string[]>; // roleId -> permissionId[]
9+
onToggle: (roleId: string, permissionId: string) => void;
10+
}
11+
12+
export const RoleMatrix: React.FC<RoleMatrixProps> = ({ roles, permissions, rolePermissions, onToggle }) => {
13+
// Group permissions by category
14+
const groupedPerms = permissions.reduce((acc, perm) => {
15+
if (!acc[perm.category]) acc[perm.category] = [];
16+
acc[perm.category].push(perm);
17+
return acc;
18+
}, {} as Record<string, Permission[]>);
19+
20+
return (
21+
<div className="overflow-x-auto border rounded-lg">
22+
<table className="min-w-full divide-y divide-gray-200">
23+
<thead className="bg-gray-50">
24+
<tr>
25+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50 z-10">
26+
Permission
27+
</th>
28+
{roles.map(role => (
29+
<th key={role.id} className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
30+
{role.name}
31+
</th>
32+
))}
33+
</tr>
34+
</thead>
35+
<tbody className="bg-white divide-y divide-gray-200">
36+
{Object.entries(groupedPerms).map(([category, perms]) => (
37+
<React.Fragment key={category}>
38+
<tr className="bg-gray-100">
39+
<td colSpan={roles.length + 1} className="px-6 py-2 text-xs font-bold text-gray-700 uppercase">
40+
{category}
41+
</td>
42+
</tr>
43+
{perms.map(perm => (
44+
<tr key={perm.id} className="hover:bg-gray-50">
45+
<td className="px-6 py-4 text-sm text-gray-900 sticky left-0 bg-white">
46+
{perm.name}
47+
</td>
48+
{roles.map(role => {
49+
const hasPerm = rolePermissions[role.id]?.includes(perm.id);
50+
const isSystem = role.isSystem; // Prevent editing super admin
51+
return (
52+
<td key={`${role.id}-${perm.id}`} className="px-6 py-4 text-center">
53+
<button
54+
onClick={() => !isSystem && onToggle(role.id, perm.id)}
55+
disabled={isSystem}
56+
className={`p-1 rounded transition-colors ${hasPerm
57+
? 'bg-green-100 text-green-700 hover:bg-green-200'
58+
: 'bg-red-50 text-red-300 hover:bg-red-100'
59+
} ${isSystem ? 'opacity-50 cursor-not-allowed' : ''}`}
60+
>
61+
{hasPerm ? <Check size={16} /> : <X size={16} />}
62+
</button>
63+
</td>
64+
);
65+
})}
66+
</tr>
67+
))}
68+
</React.Fragment>
69+
))}
70+
</tbody>
71+
</table>
72+
</div>
73+
);
74+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React from 'react';
2+
import { useForm } from 'react-hook-form';
3+
import { Role, User } from '../../../backend/src/types/admin'
4+
5+
interface UserFormProps {
6+
initialData?: Partial<User>;
7+
roles: Role[];
8+
onSubmit: (data: any) => void;
9+
onCancel: () => void;
10+
}
11+
12+
export const UserForm: React.FC<UserFormProps> = ({ initialData, roles, onSubmit, onCancel }) => {
13+
const { register, handleSubmit, formState: { errors } } = useForm({ defaultValues: initialData });
14+
15+
return (
16+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
17+
<form onSubmit={handleSubmit(onSubmit)} className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
18+
<h3 className="text-lg font-bold mb-4">{initialData?.id ? 'Edit User' : 'Invite User'}</h3>
19+
20+
<div className="space-y-4">
21+
<div>
22+
<label className="block text-sm font-medium text-gray-700">Full Name</label>
23+
<input
24+
{...register('name', { required: 'Name is required' })}
25+
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
26+
/>
27+
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message as string}</p>}
28+
</div>
29+
30+
<div>
31+
<label className="block text-sm font-medium text-gray-700">Email Address</label>
32+
<input
33+
type="email"
34+
{...register('email', { required: 'Email is required' })}
35+
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
36+
/>
37+
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message as string}</p>}
38+
</div>
39+
40+
<div>
41+
<label className="block text-sm font-medium text-gray-700">Role</label>
42+
<select
43+
{...register('role', { required: 'Role is required' })}
44+
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
45+
>
46+
<option value="">Select a role...</option>
47+
{roles.map(r => <option key={r.id} value={r.name}>{r.name}</option>)}
48+
</select>
49+
</div>
50+
51+
<div>
52+
<label className="block text-sm font-medium text-gray-700">Department</label>
53+
<select
54+
{...register('department')}
55+
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
56+
>
57+
<option value="Engineering">Engineering</option>
58+
<option value="Marketing">Marketing</option>
59+
<option value="Sales">Sales</option>
60+
<option value="HR">HR</option>
61+
</select>
62+
</div>
63+
</div>
64+
65+
<div className="mt-6 flex justify-end gap-3">
66+
<button type="button" onClick={onCancel} className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200">
67+
Cancel
68+
</button>
69+
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
70+
{initialData?.id ? 'Save Changes' : 'Send Invitation'}
71+
</button>
72+
</div>
73+
</form>
74+
</div>
75+
);
76+
};
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import React, { useMemo } from 'react';
2+
import { useReactTable, getCoreRowModel, flexRender, ColumnDef } from '@tanstack/react-table';
3+
import { Edit, Trash2, ShieldCheck } from 'lucide-react';
4+
import { User } from '../../../backend/src/types/admin'
5+
6+
interface UserTableProps {
7+
data: User[];
8+
onEdit: (user: User) => void;
9+
onDelete: (id: string) => void;
10+
}
11+
12+
export const UserTable: React.FC<UserTableProps> = ({ data, onEdit, onDelete }) => {
13+
const columns = useMemo<ColumnDef<User>[]>(() => [
14+
{ header: 'Name', accessorKey: 'name', cell: info => <span className="font-medium">{info.getValue() as string}</span> },
15+
{ header: 'Email', accessorKey: 'email' },
16+
{
17+
header: 'Role',
18+
accessorKey: 'role',
19+
cell: info => (
20+
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
21+
<ShieldCheck className="w-3 h-3 mr-1" /> {info.getValue() as string}
22+
</span>
23+
)
24+
},
25+
{ header: 'Department', accessorKey: 'department' },
26+
{
27+
header: 'Status',
28+
accessorKey: 'status',
29+
cell: info => {
30+
const status = info.getValue() as string;
31+
return (
32+
<span className={`px-2 py-1 rounded-full text-xs ${status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
33+
{status}
34+
</span>
35+
);
36+
}
37+
},
38+
{ header: 'Last Active', accessorKey: 'lastActive' },
39+
{
40+
id: 'actions',
41+
cell: ({ row }) => (
42+
<div className="flex gap-2">
43+
<button onClick={() => onEdit(row.original)} className="p-1 hover:bg-gray-100 rounded text-gray-600">
44+
<Edit className="w-4 h-4" />
45+
</button>
46+
<button onClick={() => onDelete(row.original.id)} className="p-1 hover:bg-red-100 rounded text-red-600">
47+
<Trash2 className="w-4 h-4" />
48+
</button>
49+
</div>
50+
)
51+
}
52+
], []);
53+
54+
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });
55+
56+
return (
57+
<div className="rounded-md border border-gray-200 overflow-hidden">
58+
<table className="w-full text-sm text-left">
59+
<thead className="bg-gray-50 border-b">
60+
{table.getHeaderGroups().map(headerGroup => (
61+
<tr key={headerGroup.id}>
62+
{headerGroup.headers.map(header => (
63+
<th key={header.id} className="px-6 py-3 font-medium text-gray-500">
64+
{flexRender(header.column.columnDef.header, header.getContext())}
65+
</th>
66+
))}
67+
</tr>
68+
))}
69+
</thead>
70+
<tbody className="divide-y divide-gray-200 bg-white">
71+
{table.getRowModel().rows.map(row => (
72+
<tr key={row.id} className="hover:bg-gray-50">
73+
{row.getVisibleCells().map(cell => (
74+
<td key={cell.id} className="px-6 py-4">
75+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
76+
</td>
77+
))}
78+
</tr>
79+
))}
80+
</tbody>
81+
</table>
82+
</div>
83+
);
84+
};

0 commit comments

Comments
 (0)