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,
|
Shield, Check, Clock, Bike, User, Phone,
|
||||||
MapPin, FileText, Image, DollarSign, Wrench, Battery,
|
MapPin, FileText, Image, DollarSign, Wrench, Battery,
|
||||||
CheckCircle, XCircle, ArrowLeft, Save, Printer, Send,
|
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';
|
} from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { hasPermission, canApproveKycDocument, canRejectKycDocument, canMakeValidUser } from '@/lib/auth';
|
||||||
|
|
||||||
type ApplicationSource = 'app' | 'web' | 'walkin' | 'referral';
|
type ApplicationSource = 'app' | 'web' | 'walkin' | 'referral';
|
||||||
type KYCType = 'biker' | 'investor' | 'swapstation' | 'merchant' | 'general';
|
type KYCType = 'biker' | 'investor' | 'swapstation' | 'merchant' | 'general';
|
||||||
@@ -266,6 +267,18 @@ export default function KYCDetailPage() {
|
|||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [uploadDocId, setUploadDocId] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const found = mockRequests.find(r => r.id === id);
|
const found = mockRequests.find(r => r.id === id);
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -425,7 +438,7 @@ export default function KYCDetailPage() {
|
|||||||
<>
|
<>
|
||||||
{/* Top Row on Mobile: Make [Type] Button */}
|
{/* Top Row on Mobile: Make [Type] Button */}
|
||||||
<div className="flex gap-2 w-full sm:w-auto">
|
<div className="flex gap-2 w-full sm:w-auto">
|
||||||
{request.type === 'biker' && request.status !== 'approved' && (
|
{permMakeValid && request.type === 'biker' && request.status !== 'approved' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowApproveModal(true)}
|
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"
|
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>
|
<Bike className="w-4 h-4" /> <span>Make Biker</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{request.type === 'investor' && request.status !== 'approved' && (
|
{permMakeValid && request.type === 'investor' && request.status !== 'approved' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Approve this request and create investor profile?')) {
|
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>
|
<DollarSign className="w-4 h-4" /> <span>Make Investor</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{request.type === 'swapstation' && request.status !== 'approved' && (
|
{permMakeValid && request.type === 'swapstation' && request.status !== 'approved' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Approve this request and create shop profile?')) {
|
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>
|
<Store className="w-4 h-4" /> <span>Make Shop</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{request.type === 'merchant' && request.status !== 'approved' && (
|
{permMakeValid && request.type === 'merchant' && request.status !== 'approved' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Approve this request and create merchant profile?')) {
|
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>
|
<User className="w-4 h-4" /> <span>Make Merchant</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Row on Mobile: Edit, Note, SMS */}
|
{/* Bottom Row on Mobile: Edit, Note, SMS */}
|
||||||
@@ -663,15 +681,29 @@ export default function KYCDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{doc.status === 'pending' && (
|
{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 && (
|
{(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>
|
<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' && (
|
{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>
|
{permApprove ? (
|
||||||
<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>
|
<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>}
|
{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('userRole', user.role);
|
||||||
sessionStorage.setItem('userName', user.name);
|
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) {
|
switch (user.role) {
|
||||||
case 'super_admin':
|
case 'super_admin':
|
||||||
case 'admin_manager':
|
case 'admin_manager':
|
||||||
@@ -76,6 +88,18 @@ export default function LoginPage() {
|
|||||||
sessionStorage.setItem('userRole', user.role);
|
sessionStorage.setItem('userRole', user.role);
|
||||||
sessionStorage.setItem('userName', user.name);
|
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) {
|
switch (user.role) {
|
||||||
case 'super_admin':
|
case 'super_admin':
|
||||||
case 'admin_manager':
|
case 'admin_manager':
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Bike,
|
Bike,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -24,6 +24,18 @@ import {
|
|||||||
Calculator,
|
Calculator,
|
||||||
Wrench
|
Wrench
|
||||||
} from 'lucide-react';
|
} 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 = [
|
const adminNavItems = [
|
||||||
{ label: 'Dashboard', href: '/admin', icon: BarChart3 },
|
{ label: 'Dashboard', href: '/admin', icon: BarChart3 },
|
||||||
@@ -67,11 +79,20 @@ export default function Sidebar() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [expandedMenu, setExpandedMenu] = useState<string | null>(null);
|
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 isAdmin = pathname.startsWith('/admin');
|
||||||
const isInvestor = pathname.startsWith('/investor');
|
const isInvestor = pathname.startsWith('/investor');
|
||||||
const isShop = pathname.startsWith('/shop');
|
const isShop = pathname.startsWith('/shop');
|
||||||
|
|
||||||
|
const roleLabel = ROLE_LABELS[userRole] || userRole;
|
||||||
|
|
||||||
const navItems = isAdmin ? adminNavItems :
|
const navItems = isAdmin ? adminNavItems :
|
||||||
isInvestor ? investorNavItems :
|
isInvestor ? investorNavItems :
|
||||||
isShop ? shopNavItems : bikerNavItems;
|
isShop ? shopNavItems : bikerNavItems;
|
||||||
@@ -109,7 +130,7 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="px-2 py-1 bg-accent-light rounded text-xs font-semibold text-accent">
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen(false)}
|
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">
|
<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">
|
<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">
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-slate-700 truncate">Admin User</p>
|
<p className="text-sm font-medium text-slate-700 truncate">{userName}</p>
|
||||||
<p className="text-xs text-slate-400">admin@jaiben.com</p>
|
<p className="text-xs text-slate-400">{roleLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Import and call logout function
|
logout();
|
||||||
// Note: We can't use client-only imports in server components directly
|
window.location.href = '/login';
|
||||||
// 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';
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className="p-1.5 hover:bg-slate-100 rounded-lg"
|
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 => {
|
export const isAuthenticated = (): boolean => {
|
||||||
return typeof window !== 'undefined' && !!sessionStorage.getItem('authToken');
|
return typeof window !== 'undefined' && !!sessionStorage.getItem('authToken');
|
||||||
};
|
};
|
||||||
@@ -10,10 +21,36 @@ export const getUserName = (): string | null => {
|
|||||||
return typeof window !== 'undefined' ? sessionStorage.getItem('userName') : 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 = () => {
|
export const logout = () => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
sessionStorage.removeItem('authToken');
|
sessionStorage.removeItem('authToken');
|
||||||
sessionStorage.removeItem('userRole');
|
sessionStorage.removeItem('userRole');
|
||||||
sessionStorage.removeItem('userName');
|
sessionStorage.removeItem('userName');
|
||||||
|
sessionStorage.removeItem('userPermissions');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user