feat: implement role-based access control for KYC workflows and add permissions documentation
This commit is contained in:
@@ -7,9 +7,10 @@ import {
|
||||
Shield, Check, Clock, Bike, User, Phone,
|
||||
MapPin, FileText, Image, DollarSign, Wrench, Battery,
|
||||
CheckCircle, XCircle, ArrowLeft, Save, Printer, Send,
|
||||
MessageSquare, Edit, UserCheck, Wallet, Store, Globe, Calendar, Briefcase, Plus, Upload
|
||||
MessageSquare, Edit, UserCheck, Wallet, Store, Globe, Calendar, Briefcase, Plus, Upload, Lock
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { hasPermission, canApproveKycDocument, canRejectKycDocument, canMakeValidUser } from '@/lib/auth';
|
||||
|
||||
type ApplicationSource = 'app' | 'web' | 'walkin' | 'referral';
|
||||
type KYCType = 'biker' | 'investor' | 'swapstation' | 'merchant' | 'general';
|
||||
@@ -266,6 +267,18 @@ export default function KYCDetailPage() {
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [uploadDocId, setUploadDocId] = useState<string | null>(null);
|
||||
|
||||
const [permApprove, setPermApprove] = useState(false);
|
||||
const [permReject, setPermReject] = useState(false);
|
||||
const [permMakeValid, setPermMakeValid] = useState(false);
|
||||
const [permDocUpload, setPermDocUpload] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPermApprove(canApproveKycDocument());
|
||||
setPermReject(canRejectKycDocument());
|
||||
setPermMakeValid(canMakeValidUser());
|
||||
setPermDocUpload(hasPermission('kyc.doc_upload'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const found = mockRequests.find(r => r.id === id);
|
||||
if (found) {
|
||||
@@ -425,7 +438,7 @@ export default function KYCDetailPage() {
|
||||
<>
|
||||
{/* Top Row on Mobile: Make [Type] Button */}
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
{request.type === 'biker' && request.status !== 'approved' && (
|
||||
{permMakeValid && request.type === 'biker' && request.status !== 'approved' && (
|
||||
<button
|
||||
onClick={() => setShowApproveModal(true)}
|
||||
className="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
@@ -433,7 +446,7 @@ export default function KYCDetailPage() {
|
||||
<Bike className="w-4 h-4" /> <span>Make Biker</span>
|
||||
</button>
|
||||
)}
|
||||
{request.type === 'investor' && request.status !== 'approved' && (
|
||||
{permMakeValid && request.type === 'investor' && request.status !== 'approved' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Approve this request and create investor profile?')) {
|
||||
@@ -446,7 +459,7 @@ export default function KYCDetailPage() {
|
||||
<DollarSign className="w-4 h-4" /> <span>Make Investor</span>
|
||||
</button>
|
||||
)}
|
||||
{request.type === 'swapstation' && request.status !== 'approved' && (
|
||||
{permMakeValid && request.type === 'swapstation' && request.status !== 'approved' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Approve this request and create shop profile?')) {
|
||||
@@ -459,7 +472,7 @@ export default function KYCDetailPage() {
|
||||
<Store className="w-4 h-4" /> <span>Make Shop</span>
|
||||
</button>
|
||||
)}
|
||||
{request.type === 'merchant' && request.status !== 'approved' && (
|
||||
{permMakeValid && request.type === 'merchant' && request.status !== 'approved' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Approve this request and create merchant profile?')) {
|
||||
@@ -472,6 +485,11 @@ export default function KYCDetailPage() {
|
||||
<User className="w-4 h-4" /> <span>Make Merchant</span>
|
||||
</button>
|
||||
)}
|
||||
{!permMakeValid && (
|
||||
<div className="flex items-center gap-1.5 px-3 sm:px-4 py-2 bg-slate-100 text-slate-400 rounded-lg text-xs">
|
||||
<Lock className="w-3 h-3" /> <span>Make Valid User</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Row on Mobile: Edit, Note, SMS */}
|
||||
@@ -663,15 +681,29 @@ export default function KYCDetailPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{doc.status === 'pending' && (
|
||||
<button onClick={() => openUploadModal(doc.id)} className="p-1 bg-amber-100 text-amber-600 rounded hover:bg-amber-200" title="Upload"><Upload className="w-4 h-4" /></button>
|
||||
<>
|
||||
{permDocUpload ? (
|
||||
<button onClick={() => openUploadModal(doc.id)} className="p-1 bg-amber-100 text-amber-600 rounded hover:bg-amber-200" title="Upload"><Upload className="w-4 h-4" /></button>
|
||||
) : (
|
||||
<span className="p-1 bg-amber-50 text-amber-300 rounded cursor-not-allowed" title="No permission"><Upload className="w-4 h-4" /></span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(doc.status === 'uploaded' || doc.status === 'approved') && doc.imageUrl && (
|
||||
<button onClick={() => window.open(doc.imageUrl, '_blank')} className="p-1 bg-blue-100 text-blue-600 rounded hover:bg-blue-200" title="View"><Image className="w-4 h-4" /></button>
|
||||
)}
|
||||
{doc.status === 'uploaded' && (
|
||||
<>
|
||||
<button onClick={() => handleApproveDocument(doc.id)} className="p-1 bg-green-100 text-green-600 rounded hover:bg-green-200" title="Approve"><CheckCircle className="w-4 h-4" /></button>
|
||||
<button onClick={() => openRejectDocModal(doc.id)} className="p-1 bg-red-100 text-red-600 rounded hover:bg-red-200" title="Reject"><XCircle className="w-4 h-4" /></button>
|
||||
{permApprove ? (
|
||||
<button onClick={() => handleApproveDocument(doc.id)} className="p-1 bg-green-100 text-green-600 rounded hover:bg-green-200" title="Approve"><CheckCircle className="w-4 h-4" /></button>
|
||||
) : (
|
||||
<span className="p-1 bg-green-50 text-green-300 rounded cursor-not-allowed" title="No permission"><CheckCircle className="w-4 h-4" /></span>
|
||||
)}
|
||||
{permReject ? (
|
||||
<button onClick={() => openRejectDocModal(doc.id)} className="p-1 bg-red-100 text-red-600 rounded hover:bg-red-200" title="Reject"><XCircle className="w-4 h-4" /></button>
|
||||
) : (
|
||||
<span className="p-1 bg-red-50 text-red-300 rounded cursor-not-allowed" title="No permission"><XCircle className="w-4 h-4" /></span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{doc.status === 'approved' && <span className="text-xs px-2 py-1 bg-green-100 text-green-700 rounded-full">Approved</span>}
|
||||
|
||||
@@ -35,6 +35,18 @@ export default function LoginPage() {
|
||||
sessionStorage.setItem('userRole', user.role);
|
||||
sessionStorage.setItem('userName', user.name);
|
||||
|
||||
const rolePerms: Record<string, string[]> = {
|
||||
super_admin: ['kyc.request', 'kyc.view', 'kyc.doc_upload', 'kyc.doc_approve', 'kyc.doc_reject', 'kyc.make_valid_user', 'dashboard.view'],
|
||||
admin_manager: ['kyc.request', 'kyc.view', 'kyc.doc_upload', 'kyc.doc_approve', 'kyc.doc_reject', 'kyc.make_valid_user', 'dashboard.view'],
|
||||
staff: ['kyc.request', 'kyc.view', 'kyc.doc_upload', 'dashboard.view'],
|
||||
accountant: ['dashboard.view', 'accounting.view', 'accounting.create', 'accounting.edit', 'accounting.delete'],
|
||||
investor: ['dashboard.view', 'kyc.request', 'kyc.view'],
|
||||
biker: ['dashboard.view', 'kyc.request', 'kyc.view', 'rentals.view', 'rentals.create'],
|
||||
'swap-station': ['dashboard.view', 'kyc.request', 'kyc.view'],
|
||||
merchant: ['dashboard.view', 'kyc.request', 'kyc.view', 'merchants.view'],
|
||||
};
|
||||
sessionStorage.setItem('userPermissions', JSON.stringify(rolePerms[user.role] || []));
|
||||
|
||||
switch (user.role) {
|
||||
case 'super_admin':
|
||||
case 'admin_manager':
|
||||
@@ -76,6 +88,18 @@ export default function LoginPage() {
|
||||
sessionStorage.setItem('userRole', user.role);
|
||||
sessionStorage.setItem('userName', user.name);
|
||||
|
||||
const rolePerms: Record<string, string[]> = {
|
||||
super_admin: ['kyc.request', 'kyc.view', 'kyc.doc_upload', 'kyc.doc_approve', 'kyc.doc_reject', 'kyc.make_valid_user', 'dashboard.view'],
|
||||
admin_manager: ['kyc.request', 'kyc.view', 'kyc.doc_upload', 'kyc.doc_approve', 'kyc.doc_reject', 'kyc.make_valid_user', 'dashboard.view'],
|
||||
staff: ['kyc.request', 'kyc.view', 'kyc.doc_upload', 'dashboard.view'],
|
||||
accountant: ['dashboard.view', 'accounting.view', 'accounting.create', 'accounting.edit', 'accounting.delete'],
|
||||
investor: ['dashboard.view', 'kyc.request', 'kyc.view'],
|
||||
biker: ['dashboard.view', 'kyc.request', 'kyc.view', 'rentals.view', 'rentals.create'],
|
||||
'swap-station': ['dashboard.view', 'kyc.request', 'kyc.view'],
|
||||
merchant: ['dashboard.view', 'kyc.request', 'kyc.view', 'merchants.view'],
|
||||
};
|
||||
sessionStorage.setItem('userPermissions', JSON.stringify(rolePerms[user.role] || []));
|
||||
|
||||
switch (user.role) {
|
||||
case 'super_admin':
|
||||
case 'admin_manager':
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Bike,
|
||||
Settings,
|
||||
@@ -24,6 +24,18 @@ import {
|
||||
Calculator,
|
||||
Wrench
|
||||
} from 'lucide-react';
|
||||
import { getUserName, getUserRole, logout } from '@/lib/auth';
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
super_admin: 'Super Admin',
|
||||
admin_manager: 'Admin Manager',
|
||||
staff: 'Front Desk',
|
||||
accountant: 'Accountant',
|
||||
investor: 'Investor',
|
||||
biker: 'Biker',
|
||||
'swap-station': 'Swap Station',
|
||||
merchant: 'Merchant',
|
||||
};
|
||||
|
||||
const adminNavItems = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: BarChart3 },
|
||||
@@ -67,11 +79,20 @@ export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [expandedMenu, setExpandedMenu] = useState<string | null>(null);
|
||||
const [userName, setUserName] = useState('User');
|
||||
const [userRole, setUserRole] = useState('admin');
|
||||
|
||||
useEffect(() => {
|
||||
setUserName(getUserName() || 'User');
|
||||
setUserRole(getUserRole() || 'staff');
|
||||
}, []);
|
||||
|
||||
const isAdmin = pathname.startsWith('/admin');
|
||||
const isInvestor = pathname.startsWith('/investor');
|
||||
const isShop = pathname.startsWith('/shop');
|
||||
|
||||
const roleLabel = ROLE_LABELS[userRole] || userRole;
|
||||
|
||||
const navItems = isAdmin ? adminNavItems :
|
||||
isInvestor ? investorNavItems :
|
||||
isShop ? shopNavItems : bikerNavItems;
|
||||
@@ -109,7 +130,7 @@ export default function Sidebar() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="px-2 py-1 bg-accent-light rounded text-xs font-semibold text-accent">
|
||||
{isAdmin ? 'Admin' : isInvestor ? 'Investor' : isShop ? 'Shop' : 'Biker'}
|
||||
{roleLabel}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileOpen(false)}
|
||||
@@ -148,23 +169,16 @@ export default function Sidebar() {
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 border-t border-slate-100 bg-white">
|
||||
<Link href="/admin/users/USR-001" className="flex items-center gap-3 px-3 py-2 hover:bg-slate-50 rounded-lg -mx-1">
|
||||
<div className="w-8 h-8 rounded-full bg-accent-light flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-accent">A</span>
|
||||
<span className="text-sm font-bold text-accent">{userName.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700 truncate">Admin User</p>
|
||||
<p className="text-xs text-slate-400">admin@jaiben.com</p>
|
||||
<p className="text-sm font-medium text-slate-700 truncate">{userName}</p>
|
||||
<p className="text-xs text-slate-400">{roleLabel}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Import and call logout function
|
||||
// Note: We can't use client-only imports in server components directly
|
||||
// For now, we'll just clear sessionStorage and redirect
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.removeItem('authToken');
|
||||
window.sessionStorage.removeItem('userRole');
|
||||
window.sessionStorage.removeItem('userName');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
logout();
|
||||
window.location.href = '/login';
|
||||
}}
|
||||
className="p-1.5 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
super_admin: ['kyc.request', 'kyc.view', 'kyc.doc_upload', 'kyc.doc_approve', 'kyc.doc_reject', 'kyc.make_valid_user', 'dashboard.view'],
|
||||
admin_manager: ['kyc.request', 'kyc.view', 'kyc.doc_upload', 'kyc.doc_approve', 'kyc.doc_reject', 'kyc.make_valid_user', 'dashboard.view'],
|
||||
staff: ['kyc.request', 'kyc.view', 'kyc.doc_upload', 'dashboard.view'],
|
||||
accountant: ['dashboard.view', 'accounting.view', 'accounting.create', 'accounting.edit', 'accounting.delete'],
|
||||
investor: ['dashboard.view', 'kyc.request', 'kyc.view'],
|
||||
biker: ['dashboard.view', 'kyc.request', 'kyc.view', 'rentals.view', 'rentals.create'],
|
||||
'swap-station': ['dashboard.view', 'kyc.request', 'kyc.view'],
|
||||
merchant: ['dashboard.view', 'kyc.request', 'kyc.view', 'merchants.view'],
|
||||
};
|
||||
|
||||
export const isAuthenticated = (): boolean => {
|
||||
return typeof window !== 'undefined' && !!sessionStorage.getItem('authToken');
|
||||
};
|
||||
@@ -10,10 +21,36 @@ export const getUserName = (): string | null => {
|
||||
return typeof window !== 'undefined' ? sessionStorage.getItem('userName') : null;
|
||||
};
|
||||
|
||||
export const getUserPermissions = (): string[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const stored = sessionStorage.getItem('userPermissions');
|
||||
if (stored) return JSON.parse(stored);
|
||||
const role = getUserRole();
|
||||
return role ? (ROLE_PERMISSIONS[role] || []) : [];
|
||||
};
|
||||
|
||||
export const hasPermission = (permission: string): boolean => {
|
||||
const permissions = getUserPermissions();
|
||||
return permissions.includes(permission);
|
||||
};
|
||||
|
||||
export const canApproveKycDocument = (): boolean => {
|
||||
return hasPermission('kyc.doc_approve');
|
||||
};
|
||||
|
||||
export const canRejectKycDocument = (): boolean => {
|
||||
return hasPermission('kyc.doc_reject');
|
||||
};
|
||||
|
||||
export const canMakeValidUser = (): boolean => {
|
||||
return hasPermission('kyc.make_valid_user');
|
||||
};
|
||||
|
||||
export const logout = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem('userRole');
|
||||
sessionStorage.removeItem('userName');
|
||||
sessionStorage.removeItem('userPermissions');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user