Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/src/services/admin.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import axios from 'axios';
import { User, Role } from '../types/admin';

const api = axios.create({ baseURL: '/api' });

export const AdminService = {
getUsers: () => api.get<User[]>('/users'),
createUser: (data: Partial<User>) => api.post('/users', data),
updateUser: (id: string, data: Partial<User>) =>
api.put(`/users/${id}`, data),
deleteUser: (id: string) => api.delete(`/users/${id}`),
getRoles: () => api.get<Role[]>('/roles'),
updatePermissions: (roleId: string, permissions: string[]) =>
api.put(`/roles/${roleId}/permissions`, { permissions }),
};
25 changes: 25 additions & 0 deletions backend/src/types/admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export type User = {
id: string;
name: string;
email: string;
role: string;
department: string;
status: 'active' | 'inactive';
lastActive: string;
};

export type Role = {
id: string;
name: string;
description: string;
isSystem?: boolean; // Cannot delete
};

export type Permission = {
id: string;
category: 'Assets' | 'Departments' | 'Reports' | 'Users' | 'Settings';
action: string; // e.g., 'view', 'create'
name: string; // Display name
};

export type RolePermission = Record<string, string[]>; // roleId -> permissionIds[]
74 changes: 74 additions & 0 deletions frontend/components/admin/RoleMatrix.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import { Role, Permission } from '../../../backend/src/types/admin'
import { Check, X } from 'lucide-react';

interface RoleMatrixProps {
roles: Role[];
permissions: Permission[];
rolePermissions: Record<string, string[]>; // roleId -> permissionId[]
onToggle: (roleId: string, permissionId: string) => void;
}

export const RoleMatrix: React.FC<RoleMatrixProps> = ({ roles, permissions, rolePermissions, onToggle }) => {
// Group permissions by category
const groupedPerms = permissions.reduce((acc, perm) => {
if (!acc[perm.category]) acc[perm.category] = [];
acc[perm.category].push(perm);
return acc;
}, {} as Record<string, Permission[]>);

return (
<div className="overflow-x-auto border rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<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">
Permission
</th>
{roles.map(role => (
<th key={role.id} className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
{role.name}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{Object.entries(groupedPerms).map(([category, perms]) => (
<React.Fragment key={category}>
<tr className="bg-gray-100">
<td colSpan={roles.length + 1} className="px-6 py-2 text-xs font-bold text-gray-700 uppercase">
{category}
</td>
</tr>
{perms.map(perm => (
<tr key={perm.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm text-gray-900 sticky left-0 bg-white">
{perm.name}
</td>
{roles.map(role => {
const hasPerm = rolePermissions[role.id]?.includes(perm.id);
const isSystem = role.isSystem; // Prevent editing super admin
return (
<td key={`${role.id}-${perm.id}`} className="px-6 py-4 text-center">
<button
onClick={() => !isSystem && onToggle(role.id, perm.id)}
disabled={isSystem}
className={`p-1 rounded transition-colors ${hasPerm
? 'bg-green-100 text-green-700 hover:bg-green-200'
: 'bg-red-50 text-red-300 hover:bg-red-100'
} ${isSystem ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{hasPerm ? <Check size={16} /> : <X size={16} />}
</button>
</td>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
);
};
76 changes: 76 additions & 0 deletions frontend/components/admin/UserForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { Role, User } from '../../../backend/src/types/admin'

interface UserFormProps {
initialData?: Partial<User>;
roles: Role[];
onSubmit: (data: any) => void;
onCancel: () => void;
}

export const UserForm: React.FC<UserFormProps> = ({ initialData, roles, onSubmit, onCancel }) => {
const { register, handleSubmit, formState: { errors } } = useForm({ defaultValues: initialData });

return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<form onSubmit={handleSubmit(onSubmit)} className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-bold mb-4">{initialData?.id ? 'Edit User' : 'Invite User'}</h3>

<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Full Name</label>
<input
{...register('name', { required: 'Name is required' })}
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"
/>
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name.message as string}</p>}
</div>

<div>
<label className="block text-sm font-medium text-gray-700">Email Address</label>
<input
type="email"
{...register('email', { required: 'Email is required' })}
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"
/>
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message as string}</p>}
</div>

<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<select
{...register('role', { required: 'Role is required' })}
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"
>
<option value="">Select a role...</option>
{roles.map(r => <option key={r.id} value={r.name}>{r.name}</option>)}
</select>
</div>

<div>
<label className="block text-sm font-medium text-gray-700">Department</label>
<select
{...register('department')}
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"
>
<option value="Engineering">Engineering</option>
<option value="Marketing">Marketing</option>
<option value="Sales">Sales</option>
<option value="HR">HR</option>
</select>
</div>
</div>

<div className="mt-6 flex justify-end gap-3">
<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">
Cancel
</button>
<button type="submit" className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700">
{initialData?.id ? 'Save Changes' : 'Send Invitation'}
</button>
</div>
</form>
</div>
);
};
84 changes: 84 additions & 0 deletions frontend/components/admin/UserTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { useMemo } from 'react';
import { useReactTable, getCoreRowModel, flexRender, ColumnDef } from '@tanstack/react-table';
import { Edit, Trash2, ShieldCheck } from 'lucide-react';
import { User } from '../../../backend/src/types/admin'

interface UserTableProps {
data: User[];
onEdit: (user: User) => void;
onDelete: (id: string) => void;
}

export const UserTable: React.FC<UserTableProps> = ({ data, onEdit, onDelete }) => {
const columns = useMemo<ColumnDef<User>[]>(() => [
{ header: 'Name', accessorKey: 'name', cell: info => <span className="font-medium">{info.getValue() as string}</span> },
{ header: 'Email', accessorKey: 'email' },
{
header: 'Role',
accessorKey: 'role',
cell: info => (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<ShieldCheck className="w-3 h-3 mr-1" /> {info.getValue() as string}
</span>
)
},
{ header: 'Department', accessorKey: 'department' },
{
header: 'Status',
accessorKey: 'status',
cell: info => {
const status = info.getValue() as string;
return (
<span className={`px-2 py-1 rounded-full text-xs ${status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}>
{status}
</span>
);
}
},
{ header: 'Last Active', accessorKey: 'lastActive' },
{
id: 'actions',
cell: ({ row }) => (
<div className="flex gap-2">
<button onClick={() => onEdit(row.original)} className="p-1 hover:bg-gray-100 rounded text-gray-600">
<Edit className="w-4 h-4" />
</button>
<button onClick={() => onDelete(row.original.id)} className="p-1 hover:bg-red-100 rounded text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
)
}
], []);

const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() });

return (
<div className="rounded-md border border-gray-200 overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-gray-50 border-b">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id} className="px-6 py-3 font-medium text-gray-500">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{table.getRowModel().rows.map(row => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-6 py-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
Loading