diff --git a/.gitignore b/.gitignore index 73057bfb..892c8211 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,8 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts +# permissions audit log (auto-generated) +data/permissions-audit.log + # Claude Code local settings .claude/settings.local.json diff --git a/data/permissions.json b/data/permissions.json new file mode 100644 index 00000000..2a16a730 --- /dev/null +++ b/data/permissions.json @@ -0,0 +1,61 @@ +{ + "defaultRole": "agent", + "roles": [ + { + "name": "admin", + "label": "Admin", + "description": "Full access to all features including role management", + "permissions": [ + "admin:access", + "admin:manage_roles", + "tickets:view_all", + "tickets:view_own", + "tickets:create", + "tickets:edit", + "tickets:assign", + "tickets:change_status", + "tickets:create_internal_notes", + "team:view", + "users:view", + "projects:view", + "reporting:view", + "reporting:monthly_checkpoint" + ] + }, + { + "name": "agent", + "label": "Agent", + "description": "Access to tickets, team, and reporting features", + "permissions": [ + "tickets:view_all", + "tickets:view_own", + "tickets:create", + "tickets:edit", + "tickets:assign", + "tickets:change_status", + "tickets:create_internal_notes", + "team:view", + "users:view", + "projects:view", + "reporting:view", + "reporting:monthly_checkpoint" + ] + }, + { + "name": "client", + "label": "Client", + "description": "View and create own tickets only", + "permissions": ["tickets:view_own", "tickets:create"] + } + ], + "users": [ + { + "userId": "akash.jadhav@knowall.ai", + "email": "Akash.Jadhav@knowall.ai", + "displayName": "Akash Jadhav", + "role": "admin", + "updatedAt": "2026-03-05T00:00:00.000Z", + "updatedBy": "system" + } + ] +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index dcc43dda..05dfa58e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,15 +2,21 @@ import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { MainLayout } from '@/components/layout'; -import { LoadingSpinner, AzureDevOpsIcon } from '@/components/common'; -import { Settings, Code2, CheckCircle, XCircle } from 'lucide-react'; +import { LoadingSpinner, AzureDevOpsIcon, AccessDenied } from '@/components/common'; +import { Settings, Code2, CheckCircle, XCircle, Shield } from 'lucide-react'; import { getSupportedTemplates, getTemplateConfig } from '@/config/process-templates'; +import { usePermissions } from '@/components/providers/PermissionProvider'; +import PermissionsManager from '@/components/admin/PermissionsManager'; + +type AdminTab = 'templates' | 'permissions'; export default function AdminPage() { const { data: session, status } = useSession(); const router = useRouter(); + const { hasPermission } = usePermissions(); + const [activeTab, setActiveTab] = useState('templates'); useEffect(() => { if (status === 'unauthenticated') { @@ -32,6 +38,14 @@ export default function AdminPage() { return null; } + if (!hasPermission('admin:access')) { + return ( + + + + ); + } + const supportedTemplates = getSupportedTemplates(); return ( @@ -46,199 +60,243 @@ export default function AdminPage() {

- System configuration and process template management. + System configuration, permissions, and process template management.

- {/* Process Templates Section */} -
-
- -

- Process Template Configurations -

-
-

- ZapDesk supports the following Azure DevOps process templates. Projects using - unsupported templates will display a warning. -

+ {/* Tabs */} +
+ + {hasPermission('admin:manage_roles') && ( + + )} +
-
- {supportedTemplates.map((templateName) => { - const config = getTemplateConfig(templateName); - return ( -
-
-
- -

- {config.name} -

+ {/* Tab Content */} + {activeTab === 'templates' && ( +
+

+ ZapDesk supports the following Azure DevOps process templates. Projects using + unsupported templates will display a warning. +

+ +
+ {supportedTemplates.map((templateName) => { + const config = getTemplateConfig(templateName); + return ( +
+
+
+ +

+ {config.name} +

+
+
- -
- {/* Ticket Types */} -
-

- Ticket Work Item Types -

-
- {config.workItemTypes.ticketTypes.map((type) => ( - +

+ Ticket Work Item Types +

+
+ {config.workItemTypes.ticketTypes.map((type) => ( + + {type} + {type === config.workItemTypes.defaultTicketType && ' (default)'} + + ))} +
+

+ Must be tagged with "ticket" tag +

+
+ + {/* Feature Type */} +
+

+ Feature Work Item Type +

+ {config.workItemTypes.featureType ? ( + - {type} - {type === config.workItemTypes.defaultTicketType && ' (default)'} + {config.workItemTypes.featureType} - ))} + ) : ( +
+ + + Not available + +
+ )}
-

- Must be tagged with "ticket" tag -

-
- {/* Feature Type */} -
-

- Feature Work Item Type -

- {config.workItemTypes.featureType ? ( + {/* Epic Type */} +
+

+ Epic Work Item Type +

- {config.workItemTypes.featureType} + {config.workItemTypes.epicType} - ) : ( -
- - - Not available - -
- )} -
- - {/* Epic Type */} -
-

- Epic Work Item Type -

- - {config.workItemTypes.epicType} - -
- - {/* Priority Field */} -
-

- Priority Field -

- {config.fields.priority ? ( -
- - - Supported - -
- ) : ( -
- - - Not available - -
- )} -
+
- {/* States */} -
-

- State Mappings -

-
- {config.states.new.length > 0 && ( -
- New: - - {config.states.new.join(', ')} + {/* Priority Field */} +
+

+ Priority Field +

+ {config.fields.priority ? ( +
+ + + Supported
- )} - {config.states.active.length > 0 && ( -
- Active: - - {config.states.active.join(', ')} + ) : ( +
+ + + Not available
)} -
- Resolved: - - {config.states.resolved.length > 0 - ? config.states.resolved.join(', ') - : 'N/A'} - -
- {config.states.closed.length > 0 && ( +
+ + {/* States */} +
+

+ State Mappings +

+
+ {config.states.new.length > 0 && ( +
+ New: + + {config.states.new.join(', ')} + +
+ )} + {config.states.active.length > 0 && ( +
+ Active: + + {config.states.active.join(', ')} + +
+ )}
- Closed: + Resolved: - {config.states.closed.join(', ')} + {config.states.resolved.length > 0 + ? config.states.resolved.join(', ') + : 'N/A'}
- )} + {config.states.closed.length > 0 && ( +
+ Closed: + + {config.states.closed.join(', ')} + +
+ )} +
-
- ); - })} -
+ ); + })} +
- {/* Request new template link */} - -
+ {/* Request new template link */} + +
+ )} + + {activeTab === 'permissions' && } ); diff --git a/src/app/api/devops/accounts/route.ts b/src/app/api/devops/accounts/route.ts index 097ce67b..6de2f792 100644 --- a/src/app/api/devops/accounts/route.ts +++ b/src/app/api/devops/accounts/route.ts @@ -1,23 +1,20 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; import type { DevOpsOrganization } from '@/types'; // Fetch Azure DevOps organizations (accounts) the user has access to export async function GET() { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + const { session } = auth; // First, get the user's profile to get their member ID const profileResponse = await fetch( 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.0', { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${session.accessToken!}`, 'Content-Type': 'application/json', }, } @@ -36,7 +33,7 @@ export async function GET() { `https://app.vssps.visualstudio.com/_apis/accounts?memberId=${memberId}&api-version=7.0`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${session.accessToken!}`, 'Content-Type': 'application/json', }, } diff --git a/src/app/api/devops/avatar/route.ts b/src/app/api/devops/avatar/route.ts index 0856d079..c0e0aef1 100644 --- a/src/app/api/devops/avatar/route.ts +++ b/src/app/api/devops/avatar/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; // Convert UUID string to base64 for Azure DevOps descriptor // The descriptor is the GUID string (with dashes) encoded as base64 @@ -14,13 +13,11 @@ function uuidToBase64(uuid: string): string { export async function GET() { try { - const session = await getServerSession(authOptions); + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + const { session } = auth; - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); const profile = await devopsService.getUserProfile(); // The descriptor format for AAD users is: aad.{base64-encoded-uuid} @@ -33,7 +30,7 @@ export async function GET() { const avatarResponse = await fetch(avatarUrl, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${session.accessToken!}`, }, }); diff --git a/src/app/api/devops/epics/[id]/route.ts b/src/app/api/devops/epics/[id]/route.ts index 891e3562..4168a36a 100644 --- a/src/app/api/devops/epics/[id]/route.ts +++ b/src/app/api/devops/epics/[id]/route.ts @@ -1,15 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('projects:view'); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { id } = await params; const epicId = parseInt(id, 10); @@ -25,7 +22,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Project parameter is required' }, { status: 400 }); } - const devOpsService = new AzureDevOpsService(session.accessToken); + const devOpsService = new AzureDevOpsService(session.accessToken!); const epic = await devOpsService.getEpicHierarchy(project, epicId); const treemapData = devOpsService.epicToTreemap(epic); diff --git a/src/app/api/devops/epics/route.ts b/src/app/api/devops/epics/route.ts index 017289fd..13844f76 100644 --- a/src/app/api/devops/epics/route.ts +++ b/src/app/api/devops/epics/route.ts @@ -1,15 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; export async function GET(request: NextRequest) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('projects:view'); + if (!isAuthed(auth)) return auth; + const { session } = auth; const searchParams = request.nextUrl.searchParams; const project = searchParams.get('project'); @@ -18,7 +15,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Project parameter is required' }, { status: 400 }); } - const devOpsService = new AzureDevOpsService(session.accessToken); + const devOpsService = new AzureDevOpsService(session.accessToken!); const epics = await devOpsService.getEpics(project); return NextResponse.json({ diff --git a/src/app/api/devops/monthly-checkpoint/route.ts b/src/app/api/devops/monthly-checkpoint/route.ts index 0a14fef7..873bc9a0 100644 --- a/src/app/api/devops/monthly-checkpoint/route.ts +++ b/src/app/api/devops/monthly-checkpoint/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService, workItemToTicket } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; import type { Ticket, TicketStatus, @@ -12,11 +11,9 @@ import type { export async function GET(request: Request) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('reporting:monthly_checkpoint'); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { searchParams } = new URL(request.url); const projectName = searchParams.get('project'); @@ -39,7 +36,7 @@ export async function GET(request: Request) { const endDate = new Date(endDateRaw); endDate.setHours(23, 59, 59, 999); - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); // Fetch all tickets for the project const workItems = await devopsService.getTickets(projectName); diff --git a/src/app/api/devops/organizations/route.ts b/src/app/api/devops/organizations/route.ts index cc6f0483..a1526d69 100644 --- a/src/app/api/devops/organizations/route.ts +++ b/src/app/api/devops/organizations/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; import type { Organization } from '@/types'; // Domain mappings for organizations @@ -20,13 +19,11 @@ const TAG_MAP: Record = { export async function GET() { try { - const session = await getServerSession(authOptions); + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + const { session } = auth; - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); const projects = await devopsService.getProjects(); const organizations: Organization[] = projects.map( diff --git a/src/app/api/devops/profile/route.ts b/src/app/api/devops/profile/route.ts index 9339852d..054df93f 100644 --- a/src/app/api/devops/profile/route.ts +++ b/src/app/api/devops/profile/route.ts @@ -1,18 +1,16 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions, getGraphToken } from '@/lib/auth'; +import { getGraphToken } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; import { getUserLocaleSettings } from '@/lib/graph'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; export async function GET() { try { - const session = await getServerSession(authOptions); + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + const { session } = auth; - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); const profile = await devopsService.getUserProfile(); // Try to get locale settings from Microsoft Graph diff --git a/src/app/api/devops/projects/[project]/members/route.ts b/src/app/api/devops/projects/[project]/members/route.ts index 8b85062c..cd3e7381 100644 --- a/src/app/api/devops/projects/[project]/members/route.ts +++ b/src/app/api/devops/projects/[project]/members/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; import type { User } from '@/types'; export async function GET( @@ -9,16 +8,14 @@ export async function GET( { params }: { params: Promise<{ project: string }> } ) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('projects:view'); + if (!isAuthed(auth)) return auth; + const { session } = auth; await params; // Consume params to satisfy Next.js const organization = request.headers.get('x-devops-org') || undefined; - const devopsService = new AzureDevOpsService(session.accessToken, organization); + const devopsService = new AzureDevOpsService(session.accessToken!, organization); // Get all users with entitlements (includes users not on specific teams) const allUsers = await devopsService.getAllUsersWithEntitlements(); diff --git a/src/app/api/devops/projects/[project]/priorities/route.ts b/src/app/api/devops/projects/[project]/priorities/route.ts index 136bb86e..fd4a4aa1 100644 --- a/src/app/api/devops/projects/[project]/priorities/route.ts +++ b/src/app/api/devops/projects/[project]/priorities/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { validateOrganizationAccess } from '@/lib/devops-auth'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; interface DevOpsField { referenceName: string; @@ -130,11 +129,9 @@ export async function GET( { params }: { params: Promise<{ project: string }> } ) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { project } = await params; const projectName = decodeURIComponent(project); @@ -144,7 +141,7 @@ export async function GET( return NextResponse.json({ error: 'No organization specified' }, { status: 400 }); } - const hasAccess = await validateOrganizationAccess(session.accessToken, organization); + const hasAccess = await validateOrganizationAccess(session.accessToken!, organization); if (!hasAccess) { return NextResponse.json( { error: 'Access denied to the specified organization' }, @@ -154,7 +151,7 @@ export async function GET( const workItemType = request.nextUrl.searchParams.get('workItemType') || 'Task'; const authHeaders = { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${session.accessToken!}`, 'Content-Type': 'application/json', }; diff --git a/src/app/api/devops/projects/[project]/workitemtypes/route.ts b/src/app/api/devops/projects/[project]/workitemtypes/route.ts index d584d1f7..d11d6727 100644 --- a/src/app/api/devops/projects/[project]/workitemtypes/route.ts +++ b/src/app/api/devops/projects/[project]/workitemtypes/route.ts @@ -1,17 +1,14 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; export async function GET( request: NextRequest, { params }: { params: Promise<{ project: string }> } ) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { project } = await params; const projectName = decodeURIComponent(project); @@ -31,7 +28,7 @@ export async function GET( `https://dev.azure.com/${organization}/${encodeURIComponent(projectName)}/_apis/wit/workitemtypes?api-version=7.0`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${session.accessToken!}`, 'Content-Type': 'application/json', }, } diff --git a/src/app/api/devops/projects/route.ts b/src/app/api/devops/projects/route.ts index e30c1131..ae64e41a 100644 --- a/src/app/api/devops/projects/route.ts +++ b/src/app/api/devops/projects/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { validateOrganizationAccess } from '@/lib/devops-auth'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; import type { Organization, SLALevel } from '@/types'; import { isTemplateSupported, getTemplateConfig } from '@/config/process-templates'; import type { ProcessTemplateConfig } from '@/config/process-templates'; @@ -185,11 +184,9 @@ async function fetchProjectProcess( export async function GET(request: NextRequest) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('projects:view'); + if (!isAuthed(auth)) return auth; + const { session } = auth; // Get organization from header (client sends from localStorage selection) const devOpsOrg = request.headers.get('x-devops-org'); @@ -199,7 +196,7 @@ export async function GET(request: NextRequest) { } // Validate user has access to the requested organization - const hasAccess = await validateOrganizationAccess(session.accessToken, devOpsOrg); + const hasAccess = await validateOrganizationAccess(session.accessToken!, devOpsOrg); if (!hasAccess) { return NextResponse.json( { error: 'Access denied to the specified organization' }, @@ -212,7 +209,7 @@ export async function GET(request: NextRequest) { // Fetch projects with description expanded const response = await fetch(`${baseUrl}/_apis/projects?api-version=7.0&$expand=description`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${session.accessToken!}`, 'Content-Type': 'application/json', }, }); diff --git a/src/app/api/devops/search/route.ts b/src/app/api/devops/search/route.ts index fadb8953..3c96d58a 100644 --- a/src/app/api/devops/search/route.ts +++ b/src/app/api/devops/search/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; interface SearchResult { type: 'ticket' | 'user' | 'organization'; @@ -14,11 +13,9 @@ interface SearchResult { export async function GET(request: NextRequest) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + const { session } = auth; const searchParams = request.nextUrl.searchParams; const query = searchParams.get('q')?.toLowerCase().trim(); @@ -27,7 +24,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ results: [] }); } - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); const results: SearchResult[] = []; // Search tickets diff --git a/src/app/api/devops/sla-status/route.ts b/src/app/api/devops/sla-status/route.ts index fb70b2e1..a10ab846 100644 --- a/src/app/api/devops/sla-status/route.ts +++ b/src/app/api/devops/sla-status/route.ts @@ -1,19 +1,16 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; import { calculateSLAStatusForTickets, sortByUrgency, getSLASummary } from '@/lib/sla'; import type { SLAStatusResponse } from '@/types'; export async function GET() { try { - const session = await getServerSession(authOptions); + const auth = await requirePermission('reporting:view'); + if (!isAuthed(auth)) return auth; + const { session } = auth; - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); const tickets = await devopsService.getAllTickets(); // Calculate SLA status for all active tickets diff --git a/src/app/api/devops/stats/route.ts b/src/app/api/devops/stats/route.ts index fd7fdce2..a312e714 100644 --- a/src/app/api/devops/stats/route.ts +++ b/src/app/api/devops/stats/route.ts @@ -1,16 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; import type { Ticket, TicketStatus } from '@/types'; export async function GET(request: NextRequest) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('reporting:view'); + if (!isAuthed(auth)) return auth; + const { session } = auth; // Get organization from header (client sends from localStorage selection) const organization = request.headers.get('x-devops-org'); @@ -19,7 +16,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'No organization specified' }, { status: 400 }); } - const devopsService = new AzureDevOpsService(session.accessToken, organization); + const devopsService = new AzureDevOpsService(session.accessToken!, organization); const tickets = await devopsService.getAllTickets(); const today = new Date(); diff --git a/src/app/api/devops/team-activity/route.ts b/src/app/api/devops/team-activity/route.ts index 8b22f14e..c878beae 100644 --- a/src/app/api/devops/team-activity/route.ts +++ b/src/app/api/devops/team-activity/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; import type { User } from '@/types'; interface ActivityData { @@ -41,16 +40,14 @@ function getActivityLevel(count: number, maxCount: number): number { export async function GET(request: Request) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('team:view'); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { searchParams } = new URL(request.url); const memberFilter = searchParams.get('member'); - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); // Get ALL work items (not just those tagged as "ticket") for accurate team activity const tickets = await devopsService.getAllTickets(false); diff --git a/src/app/api/devops/team/route.ts b/src/app/api/devops/team/route.ts index e33f8941..49004b31 100644 --- a/src/app/api/devops/team/route.ts +++ b/src/app/api/devops/team/route.ts @@ -1,22 +1,19 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; import type { TeamMember, TeamMemberStatus, TeamStats, TicketStatus, Ticket } from '@/types'; export async function GET() { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('team:view'); + if (!isAuthed(auth)) return auth; + const { session } = auth; // Get the current user's email domain to filter internal users const userEmail = session.user?.email || ''; const internalDomain = userEmail.includes('@') ? userEmail.split('@')[1].toLowerCase() : ''; - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); // Get all users from the organization const orgUsers = await devopsService.getOrganizationUsers(); diff --git a/src/app/api/devops/ticket-counts/route.ts b/src/app/api/devops/ticket-counts/route.ts index 90ba7471..54f121a6 100644 --- a/src/app/api/devops/ticket-counts/route.ts +++ b/src/app/api/devops/ticket-counts/route.ts @@ -1,16 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requireAnyPermission, isAuthed } from '@/lib/api-auth'; import type { TicketStatus } from '@/types'; export async function GET(request: NextRequest) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAnyPermission(['tickets:view_all', 'tickets:view_own']); + if (!isAuthed(auth)) return auth; + const { session } = auth; // Get organization from header (client sends from localStorage selection) const organization = request.headers.get('x-devops-org'); @@ -19,7 +16,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'No organization specified' }, { status: 400 }); } - const devopsService = new AzureDevOpsService(session.accessToken, organization); + const devopsService = new AzureDevOpsService(session.accessToken!, organization); const tickets = await devopsService.getAllTickets(); const currentUserEmail = session.user?.email?.toLowerCase(); diff --git a/src/app/api/devops/tickets/[id]/attachments/route.ts b/src/app/api/devops/tickets/[id]/attachments/route.ts index 748c9bbc..aac14401 100644 --- a/src/app/api/devops/tickets/[id]/attachments/route.ts +++ b/src/app/api/devops/tickets/[id]/attachments/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requireAnyPermission, requirePermission, isAuthed } from '@/lib/api-auth'; import { MAX_ATTACHMENT_SIZE, ALLOWED_ATTACHMENT_TYPES } from '@/types'; type RouteParams = { params: Promise<{ id: string }> }; @@ -9,11 +8,9 @@ type RouteParams = { params: Promise<{ id: string }> }; // GET - Fetch attachments for a ticket export async function GET(request: NextRequest, { params }: RouteParams) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAnyPermission(['tickets:view_all', 'tickets:view_own']); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { id } = await params; const ticketId = parseInt(id, 10); @@ -22,7 +19,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: 'Invalid ticket ID' }, { status: 400 }); } - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); const result = await devopsService.findProjectForWorkItem(ticketId); if (!result) { @@ -41,11 +38,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // POST - Upload attachment to a ticket export async function POST(request: NextRequest, { params }: RouteParams) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('tickets:edit'); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { id } = await params; const ticketId = parseInt(id, 10); @@ -82,7 +77,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { ); } - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); const found = await devopsService.findProjectForWorkItem(ticketId); if (!found) { diff --git a/src/app/api/devops/tickets/[id]/comments/route.ts b/src/app/api/devops/tickets/[id]/comments/route.ts index 7dfb828e..8d41b433 100644 --- a/src/app/api/devops/tickets/[id]/comments/route.ts +++ b/src/app/api/devops/tickets/[id]/comments/route.ts @@ -1,15 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; +import { hasPermission } from '@/lib/permissions'; export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + const { session, permissions } = auth; const { id } = await params; const ticketId = parseInt(id, 10); @@ -25,7 +23,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Comment is required' }, { status: 400 }); } - const devopsService = new AzureDevOpsService(session.accessToken); + // Block internal notes for users without permission + if (isInternal && !hasPermission(permissions, 'tickets:create_internal_notes')) { + return NextResponse.json( + { error: 'You do not have permission to create internal notes' }, + { status: 403 } + ); + } + + const devopsService = new AzureDevOpsService(session.accessToken!); // Get all projects to find the ticket const projects = await devopsService.getProjects(); diff --git a/src/app/api/devops/tickets/[id]/history/route.ts b/src/app/api/devops/tickets/[id]/history/route.ts index 8ed338a7..0b36cf76 100644 --- a/src/app/api/devops/tickets/[id]/history/route.ts +++ b/src/app/api/devops/tickets/[id]/history/route.ts @@ -1,15 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requireAnyPermission, isAuthed } from '@/lib/api-auth'; export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAnyPermission(['tickets:view_all', 'tickets:view_own']); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { id } = await params; const ticketId = parseInt(id, 10); @@ -18,7 +15,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Invalid ticket ID' }, { status: 400 }); } - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); const found = await devopsService.findProjectForWorkItem(ticketId); if (!found) { diff --git a/src/app/api/devops/tickets/[id]/route.ts b/src/app/api/devops/tickets/[id]/route.ts index c049f58d..dc69b7d7 100644 --- a/src/app/api/devops/tickets/[id]/route.ts +++ b/src/app/api/devops/tickets/[id]/route.ts @@ -1,17 +1,14 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService, workItemToTicket } from '@/lib/devops'; +import { requireAnyPermission, requirePermission, isAuthed } from '@/lib/api-auth'; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(request: NextRequest, { params }: RouteParams) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAnyPermission(['tickets:view_all', 'tickets:view_own']); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { id } = await params; const ticketId = parseInt(id, 10); @@ -20,7 +17,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: 'Invalid ticket ID' }, { status: 400 }); } - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); const found = await devopsService.findProjectForWorkItem(ticketId); if (!found) { @@ -52,11 +49,9 @@ export async function GET(request: NextRequest, { params }: RouteParams) { export async function PATCH(request: NextRequest, { params }: RouteParams) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('tickets:edit'); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { id } = await params; const ticketId = parseInt(id, 10); @@ -72,7 +67,7 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: 'Project name is required' }, { status: 400 }); } - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); // Build updates object const updates: { assignee?: string | null; priority?: number } = {}; diff --git a/src/app/api/devops/tickets/[id]/state/route.ts b/src/app/api/devops/tickets/[id]/state/route.ts index 2ff1e0af..74309621 100644 --- a/src/app/api/devops/tickets/[id]/state/route.ts +++ b/src/app/api/devops/tickets/[id]/state/route.ts @@ -1,15 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService, workItemToTicket } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('tickets:change_status'); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { id } = await params; const ticketId = parseInt(id, 10); @@ -25,7 +22,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: 'State is required' }, { status: 400 }); } - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); // Get all projects to find the ticket const projects = await devopsService.getProjects(); diff --git a/src/app/api/devops/tickets/[id]/status/route.ts b/src/app/api/devops/tickets/[id]/status/route.ts index 43f6d558..8612becc 100644 --- a/src/app/api/devops/tickets/[id]/status/route.ts +++ b/src/app/api/devops/tickets/[id]/status/route.ts @@ -1,16 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService, workItemToTicket, mapStatusToState } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; import type { TicketStatus } from '@/types'; export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('tickets:change_status'); + if (!isAuthed(auth)) return auth; + const { session } = auth; const { id } = await params; const ticketId = parseInt(id, 10); @@ -29,7 +26,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< // Map frontend status to Azure DevOps state const devOpsState = mapStatusToState(status as TicketStatus); - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); // Get all projects to find the ticket const projects = await devopsService.getProjects(); diff --git a/src/app/api/devops/tickets/route.ts b/src/app/api/devops/tickets/route.ts index cff89332..b8bad7a4 100644 --- a/src/app/api/devops/tickets/route.ts +++ b/src/app/api/devops/tickets/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { validateOrganizationAccess } from '@/lib/devops-auth'; import { AzureDevOpsService, workItemToTicket, setStateCategoryCache } from '@/lib/devops'; +import { requireAnyPermission, requirePermission, isAuthed } from '@/lib/api-auth'; +import { hasPermission } from '@/lib/permissions'; import type { Ticket, TicketStatus } from '@/types'; // TTL cache for state categories (avoids refetching on every request) @@ -86,11 +86,9 @@ async function fetchAndCacheStateCategories(accessToken: string, organization: s export async function POST(request: NextRequest) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requirePermission('tickets:create'); + if (!isAuthed(auth)) return auth; + const { session } = auth; const body = await request.json(); const { @@ -115,7 +113,7 @@ export async function POST(request: NextRequest) { } // Validate user has access to the requested organization - const hasAccess = await validateOrganizationAccess(session.accessToken, organization); + const hasAccess = await validateOrganizationAccess(session.accessToken!, organization); if (!hasAccess) { return NextResponse.json( { error: 'Access denied to the specified organization' }, @@ -123,7 +121,7 @@ export async function POST(request: NextRequest) { ); } - const devopsService = new AzureDevOpsService(session.accessToken, organization); + const devopsService = new AzureDevOpsService(session.accessToken!, organization); // Validate priorityFieldRef to prevent arbitrary field injection const allowedPriorityFields = [ @@ -163,11 +161,9 @@ export async function POST(request: NextRequest) { export async function GET(request: NextRequest) { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAnyPermission(['tickets:view_all', 'tickets:view_own']); + if (!isAuthed(auth)) return auth; + const { session, permissions } = auth; const searchParams = request.nextUrl.searchParams; const view = searchParams.get('view') || 'all-unsolved'; @@ -182,7 +178,7 @@ export async function GET(request: NextRequest) { } // Validate user has access to the requested organization - const hasAccess = await validateOrganizationAccess(session.accessToken, organization); + const hasAccess = await validateOrganizationAccess(session.accessToken!, organization); if (!hasAccess) { return NextResponse.json( { error: 'Access denied to the specified organization' }, @@ -191,13 +187,21 @@ export async function GET(request: NextRequest) { } // Fetch and cache state categories before getting tickets - await fetchAndCacheStateCategories(session.accessToken, organization); + await fetchAndCacheStateCategories(session.accessToken!, organization); - const devopsService = new AzureDevOpsService(session.accessToken, organization); + const devopsService = new AzureDevOpsService(session.accessToken!, organization); const tickets = await devopsService.getAllTickets(ticketsOnly); // Filter tickets based on view - const filteredTickets = filterTicketsByView(tickets, view, session.user?.email); + let filteredTickets = filterTicketsByView(tickets, view, session.user?.email); + + // If user only has view_own (client role), filter to their tickets only + if (!hasPermission(permissions, 'tickets:view_all')) { + const userEmail = session.user?.email?.toLowerCase(); + filteredTickets = filteredTickets.filter( + (t) => t.requester.email?.toLowerCase() === userEmail + ); + } return NextResponse.json({ tickets: filteredTickets, diff --git a/src/app/api/devops/users/route.ts b/src/app/api/devops/users/route.ts index a9eabf39..24e054fe 100644 --- a/src/app/api/devops/users/route.ts +++ b/src/app/api/devops/users/route.ts @@ -1,18 +1,15 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; import { AzureDevOpsService } from '@/lib/devops'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; import type { Customer } from '@/types'; export async function GET() { try { - const session = await getServerSession(authOptions); + const auth = await requirePermission('users:view'); + if (!isAuthed(auth)) return auth; + const { session } = auth; - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const devopsService = new AzureDevOpsService(session.accessToken); + const devopsService = new AzureDevOpsService(session.accessToken!); // Fetch tickets, projects, and all users from entitlements API in parallel const [tickets, projects, allUsersWithLicenses] = await Promise.all([ diff --git a/src/app/api/devops/workitem-states/route.ts b/src/app/api/devops/workitem-states/route.ts index 1b6b6185..b37fc335 100644 --- a/src/app/api/devops/workitem-states/route.ts +++ b/src/app/api/devops/workitem-states/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { getServerSession } from 'next-auth'; -import { authOptions } from '@/lib/auth'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; interface WorkItemState { name: string; @@ -15,11 +14,9 @@ interface WorkItemTypeStates { export async function GET() { try { - const session = await getServerSession(authOptions); - - if (!session?.accessToken) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + const { session } = auth; const organization = process.env.AZURE_DEVOPS_ORG || 'KnowAll'; @@ -28,7 +25,7 @@ export async function GET() { `https://dev.azure.com/${organization}/_apis/projects?api-version=7.0`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${session.accessToken!}`, 'Content-Type': 'application/json', }, } @@ -57,7 +54,7 @@ export async function GET() { `https://dev.azure.com/${organization}/${encodeURIComponent(firstProject)}/_apis/wit/workitemtypes/${encodeURIComponent(witType)}/states?api-version=7.0`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${session.accessToken!}`, 'Content-Type': 'application/json', }, } diff --git a/src/app/api/permissions/audit/route.ts b/src/app/api/permissions/audit/route.ts new file mode 100644 index 00000000..2365cc40 --- /dev/null +++ b/src/app/api/permissions/audit/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; +import { readAuditLog } from '@/lib/permissions'; + +// GET /api/permissions/audit - Get audit log (admin only) +export async function GET() { + try { + const auth = await requirePermission('admin:manage_roles'); + if (!isAuthed(auth)) return auth; + + const entries = readAuditLog(100); + return NextResponse.json({ entries }); + } catch (error) { + console.error('Error fetching audit log:', error); + return NextResponse.json({ error: 'Failed to fetch audit log' }, { status: 500 }); + } +} diff --git a/src/app/api/permissions/config/route.ts b/src/app/api/permissions/config/route.ts new file mode 100644 index 00000000..00755578 --- /dev/null +++ b/src/app/api/permissions/config/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from 'next/server'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; +import { readPermissionsConfig, writePermissionsConfig, appendAuditLog } from '@/lib/permissions'; +import type { PermissionsConfig } from '@/types'; + +// GET /api/permissions/config - Get full permissions config (admin only) +export async function GET() { + try { + const auth = await requirePermission('admin:manage_roles'); + if (!isAuthed(auth)) return auth; + + const config = readPermissionsConfig(); + return NextResponse.json(config); + } catch (error) { + console.error('Error fetching permissions config:', error); + return NextResponse.json({ error: 'Failed to fetch permissions config' }, { status: 500 }); + } +} + +// PUT /api/permissions/config - Update full permissions config (admin only) +export async function PUT(request: Request) { + try { + const auth = await requirePermission('admin:manage_roles'); + if (!isAuthed(auth)) return auth; + + const body = (await request.json()) as Partial; + + const config = readPermissionsConfig(); + + // Update default role if provided + if (body.defaultRole) { + config.defaultRole = body.defaultRole; + } + + // Update roles if provided + if (body.roles) { + config.roles = body.roles; + } + + writePermissionsConfig(config); + + appendAuditLog({ + timestamp: new Date().toISOString(), + action: 'config_updated', + targetUserId: '', + targetEmail: '', + performedBy: auth.session.user.id, + performedByEmail: auth.session.user.email || '', + details: `Updated permissions config (defaultRole: ${config.defaultRole})`, + }); + + return NextResponse.json({ success: true, config }); + } catch (error) { + console.error('Error updating permissions config:', error); + return NextResponse.json({ error: 'Failed to update permissions config' }, { status: 500 }); + } +} diff --git a/src/app/api/permissions/route.ts b/src/app/api/permissions/route.ts new file mode 100644 index 00000000..d32c0241 --- /dev/null +++ b/src/app/api/permissions/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; +import { requireAuth, isAuthed } from '@/lib/api-auth'; + +// GET /api/permissions - Get current user's resolved permissions +export async function GET() { + try { + const auth = await requireAuth(); + if (!isAuthed(auth)) return auth; + + return NextResponse.json({ + role: auth.permissions.role, + permissions: auth.permissions.permissions, + }); + } catch (error) { + console.error('Error fetching permissions:', error); + return NextResponse.json({ error: 'Failed to fetch permissions' }, { status: 500 }); + } +} diff --git a/src/app/api/permissions/users/[id]/route.ts b/src/app/api/permissions/users/[id]/route.ts new file mode 100644 index 00000000..71175fca --- /dev/null +++ b/src/app/api/permissions/users/[id]/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; +import { setUserOverride, removeUserOverride, appendAuditLog } from '@/lib/permissions'; +import type { UserPermissionOverride } from '@/types'; + +type RouteParams = { params: Promise<{ id: string }> }; + +// PUT /api/permissions/users/[id] - Set user override (admin only) +export async function PUT(request: NextRequest, { params }: RouteParams) { + try { + const auth = await requirePermission('admin:manage_roles'); + if (!isAuthed(auth)) return auth; + + const { id } = await params; + const body = (await request.json()) as Partial; + + if (!body.email || !body.role) { + return NextResponse.json({ error: 'email and role are required' }, { status: 400 }); + } + + const override: UserPermissionOverride = { + userId: decodeURIComponent(id), + email: body.email, + displayName: body.displayName, + role: body.role, + permissions: body.permissions, + revokedPermissions: body.revokedPermissions, + updatedAt: new Date().toISOString(), + updatedBy: auth.session.user.email || auth.session.user.id, + }; + + setUserOverride(override); + + appendAuditLog({ + timestamp: new Date().toISOString(), + action: 'role_changed', + targetUserId: override.userId, + targetEmail: override.email, + performedBy: auth.session.user.id, + performedByEmail: auth.session.user.email || '', + details: `Set role to "${override.role}" for ${override.email}`, + }); + + return NextResponse.json({ success: true, override }); + } catch (error) { + console.error('Error setting user override:', error); + return NextResponse.json({ error: 'Failed to set user override' }, { status: 500 }); + } +} + +// DELETE /api/permissions/users/[id] - Remove user override (admin only) +export async function DELETE(_request: NextRequest, { params }: RouteParams) { + try { + const auth = await requirePermission('admin:manage_roles'); + if (!isAuthed(auth)) return auth; + + const { id } = await params; + const email = decodeURIComponent(id); + const removed = removeUserOverride(email); + + if (!removed) { + return NextResponse.json({ error: 'User override not found' }, { status: 404 }); + } + + appendAuditLog({ + timestamp: new Date().toISOString(), + action: 'role_changed', + targetUserId: '', + targetEmail: email, + performedBy: auth.session.user.id, + performedByEmail: auth.session.user.email || '', + details: `Removed role override for ${email} (reverted to default role)`, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error removing user override:', error); + return NextResponse.json({ error: 'Failed to remove user override' }, { status: 500 }); + } +} diff --git a/src/app/api/permissions/users/route.ts b/src/app/api/permissions/users/route.ts new file mode 100644 index 00000000..bddaeee7 --- /dev/null +++ b/src/app/api/permissions/users/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { requirePermission, isAuthed } from '@/lib/api-auth'; +import { readPermissionsConfig } from '@/lib/permissions'; + +// GET /api/permissions/users - Get all user overrides (admin only) +export async function GET() { + try { + const auth = await requirePermission('admin:manage_roles'); + if (!isAuthed(auth)) return auth; + + const config = readPermissionsConfig(); + return NextResponse.json({ users: config.users }); + } catch (error) { + console.error('Error fetching user overrides:', error); + return NextResponse.json({ error: 'Failed to fetch user overrides' }, { status: 500 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6ef124a7..7fc06765 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import SessionProvider from '@/components/providers/SessionProvider'; import OrganizationProvider from '@/components/providers/OrganizationProvider'; +import PermissionProvider from '@/components/providers/PermissionProvider'; import './globals.css'; const siteUrl = process.env.NEXTAUTH_URL || 'https://zapdesk.knowall.ai'; @@ -73,7 +74,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/src/app/monthly-checkpoint/page.tsx b/src/app/monthly-checkpoint/page.tsx index 3ed2aec7..f8618eb6 100644 --- a/src/app/monthly-checkpoint/page.tsx +++ b/src/app/monthly-checkpoint/page.tsx @@ -6,6 +6,8 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { format, subDays, startOfMonth, endOfMonth, subMonths } from 'date-fns'; import { Calendar, Download, Plus, Loader2, RefreshCw } from 'lucide-react'; import { MainLayout } from '@/components/layout'; +import { AccessDenied } from '@/components/common'; +import { usePermissions } from '@/components/providers/PermissionProvider'; import { KPICards, TrendCharts, CheckpointTicketTable } from '@/components/monthly-checkpoint'; import { NewTicketModal } from '@/components/tickets'; import type { MonthlyCheckpointStats, DevOpsProject, Ticket } from '@/types'; @@ -15,6 +17,7 @@ type DatePreset = 'last30' | 'thisMonth' | 'lastMonth' | 'custom'; function MonthlyCheckpointContent() { const { data: session, status } = useSession(); const router = useRouter(); + const { hasPermission } = usePermissions(); const searchParams = useSearchParams(); const printRef = useRef(null); @@ -158,6 +161,14 @@ function MonthlyCheckpointContent() { return null; } + if (!hasPermission('reporting:monthly_checkpoint')) { + return ( + + + + ); + } + return (
diff --git a/src/app/reporting/page.tsx b/src/app/reporting/page.tsx index 900c9120..64727412 100644 --- a/src/app/reporting/page.tsx +++ b/src/app/reporting/page.tsx @@ -6,7 +6,8 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { format, subDays, startOfWeek } from 'date-fns'; import Link from 'next/link'; import { MainLayout } from '@/components/layout'; -import { LoadingSpinner } from '@/components/common'; +import { LoadingSpinner, AccessDenied } from '@/components/common'; +import { usePermissions } from '@/components/providers/PermissionProvider'; import { useDevOpsApi } from '@/hooks'; import { RefreshCw, @@ -45,6 +46,7 @@ interface ProjectStats { export default function LiveDashboardPage() { const { data: session, status } = useSession(); const router = useRouter(); + const { hasPermission } = usePermissions(); const { get: devOpsGet, hasOrganization } = useDevOpsApi(); const [stats, setStats] = useState(null); const [allTickets, setAllTickets] = useState([]); @@ -314,6 +316,14 @@ export default function LiveDashboardPage() { }, ]; + if (!hasPermission('reporting:view')) { + return ( + + + + ); + } + return (
diff --git a/src/app/team/page.tsx b/src/app/team/page.tsx index 85cd1fa2..48e130be 100644 --- a/src/app/team/page.tsx +++ b/src/app/team/page.tsx @@ -4,7 +4,8 @@ import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import React, { useCallback, useEffect, useState } from 'react'; import { MainLayout } from '@/components/layout'; -import { LoadingSpinner, Avatar } from '@/components/common'; +import { LoadingSpinner, Avatar, AccessDenied } from '@/components/common'; +import { usePermissions } from '@/components/providers/PermissionProvider'; import { Users2, Ticket, @@ -41,6 +42,7 @@ type SortDirection = 'asc' | 'desc'; export default function TeamPage() { const { data: session, status } = useSession(); const router = useRouter(); + const { hasPermission } = usePermissions(); const [teamData, setTeamData] = useState(null); const [activityData, setActivityData] = useState(null); const [loading, setLoading] = useState(true); @@ -229,6 +231,14 @@ export default function TeamPage() { }, ]; + if (!hasPermission('team:view')) { + return ( + + + + ); + } + return (
diff --git a/src/app/tickets/[id]/page.tsx b/src/app/tickets/[id]/page.tsx index f2fa4058..8af7b3b7 100644 --- a/src/app/tickets/[id]/page.tsx +++ b/src/app/tickets/[id]/page.tsx @@ -6,6 +6,7 @@ import { useEffect, useState, useCallback } from 'react'; import { MainLayout } from '@/components/layout'; import { LoadingSpinner } from '@/components/common'; import { TicketDetail } from '@/components/tickets'; +import { usePermissions } from '@/components/providers/PermissionProvider'; import type { Ticket, TicketComment, Attachment, WorkItemUpdate } from '@/types'; export default function TicketDetailPage() { @@ -13,6 +14,7 @@ export default function TicketDetailPage() { const router = useRouter(); const params = useParams(); const ticketId = params.id as string; + const { hasPermission } = usePermissions(); const [ticket, setTicket] = useState(null); const [comments, setComments] = useState([]); @@ -198,19 +200,27 @@ export default function TicketDetailPage() { return null; } + const canChangeStatus = hasPermission('tickets:change_status'); + const canAssign = hasPermission('tickets:assign'); + const canEdit = hasPermission('tickets:edit'); + const canSeeInternalNotes = hasPermission('tickets:create_internal_notes'); + + // Filter internal notes for users without permission + const visibleComments = canSeeInternalNotes ? comments : comments.filter((c) => !c.isInternal); + return ( diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx index c6dca8c2..74fbbb72 100644 --- a/src/app/users/page.tsx +++ b/src/app/users/page.tsx @@ -4,7 +4,8 @@ import { useSession } from 'next-auth/react'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; import { MainLayout } from '@/components/layout'; -import { Avatar, LoadingSpinner } from '@/components/common'; +import { Avatar, LoadingSpinner, AccessDenied } from '@/components/common'; +import { usePermissions } from '@/components/providers/PermissionProvider'; import { Search, Plus, Upload, ExternalLink } from 'lucide-react'; import Link from 'next/link'; import { format } from 'date-fns'; @@ -13,6 +14,7 @@ import type { Customer } from '@/types'; export default function UsersPage() { const { data: session, status } = useSession(); const router = useRouter(); + const { hasPermission } = usePermissions(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -89,6 +91,14 @@ export default function UsersPage() { return null; } + if (!hasPermission('users:view')) { + return ( + + + + ); + } + return (
diff --git a/src/components/admin/PermissionsManager.tsx b/src/components/admin/PermissionsManager.tsx new file mode 100644 index 00000000..8fcb5422 --- /dev/null +++ b/src/components/admin/PermissionsManager.tsx @@ -0,0 +1,485 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Shield, Plus, Trash2, Loader2, Clock, ChevronDown, ChevronUp, Search } from 'lucide-react'; +import type { + PermissionsConfig, + UserPermissionOverride, + UserRole, + PermissionAuditEntry, +} from '@/types'; + +const ROLE_LABELS: Record = { + admin: 'Admin', + agent: 'Agent', + client: 'Client', +}; + +const ROLE_COLORS: Record = { + admin: 'var(--priority-urgent)', + agent: 'var(--primary)', + client: 'var(--text-muted)', +}; + +export default function PermissionsManager() { + const [config, setConfig] = useState(null); + const [auditLog, setAuditLog] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [showAddUser, setShowAddUser] = useState(false); + const [showAuditLog, setShowAuditLog] = useState(false); + const [newUserEmail, setNewUserEmail] = useState(''); + const [newUserName, setNewUserName] = useState(''); + const [newUserRole, setNewUserRole] = useState('agent'); + const [searchQuery, setSearchQuery] = useState(''); + + const fetchConfig = useCallback(async () => { + try { + const response = await fetch('/api/permissions/config'); + if (response.ok) { + const data = await response.json(); + setConfig(data); + } else if (response.status === 403) { + setError('You do not have permission to manage roles.'); + } else { + setError('Failed to load permissions config.'); + } + } catch { + setError('Failed to load permissions config.'); + } finally { + setLoading(false); + } + }, []); + + const fetchAuditLog = useCallback(async () => { + try { + const response = await fetch('/api/permissions/audit'); + if (response.ok) { + const data = await response.json(); + setAuditLog(data.entries || []); + } + } catch { + // Non-critical, ignore + } + }, []); + + useEffect(() => { + fetchConfig(); + fetchAuditLog(); + }, [fetchConfig, fetchAuditLog]); + + const handleDefaultRoleChange = async (role: UserRole) => { + if (!config) return; + setSaving(true); + try { + const response = await fetch('/api/permissions/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ defaultRole: role }), + }); + if (response.ok) { + setConfig({ ...config, defaultRole: role }); + fetchAuditLog(); + } + } catch { + setError('Failed to update default role.'); + } finally { + setSaving(false); + } + }; + + const handleAddUser = async () => { + if (!newUserEmail.trim()) return; + setSaving(true); + try { + const userId = newUserEmail.toLowerCase(); + const response = await fetch(`/api/permissions/users/${encodeURIComponent(userId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: newUserEmail.trim(), + displayName: newUserName.trim() || undefined, + role: newUserRole, + }), + }); + if (response.ok) { + setNewUserEmail(''); + setNewUserName(''); + setNewUserRole('agent'); + setShowAddUser(false); + fetchConfig(); + fetchAuditLog(); + } + } catch { + setError('Failed to add user override.'); + } finally { + setSaving(false); + } + }; + + const handleChangeRole = async (user: UserPermissionOverride, newRole: UserRole) => { + setSaving(true); + try { + const response = await fetch(`/api/permissions/users/${encodeURIComponent(user.email)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: user.email, + displayName: user.displayName, + role: newRole, + }), + }); + if (response.ok) { + fetchConfig(); + fetchAuditLog(); + } + } catch { + setError('Failed to update user role.'); + } finally { + setSaving(false); + } + }; + + const handleRemoveUser = async (email: string) => { + setSaving(true); + try { + const response = await fetch(`/api/permissions/users/${encodeURIComponent(email)}`, { + method: 'DELETE', + }); + if (response.ok) { + fetchConfig(); + fetchAuditLog(); + } + } catch { + setError('Failed to remove user override.'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error && !config) { + return ( +
+

{error}

+
+ ); + } + + if (!config) return null; + + const filteredUsers = config.users.filter((u) => { + if (!searchQuery) return true; + const q = searchQuery.toLowerCase(); + return ( + u.email.toLowerCase().includes(q) || + u.displayName?.toLowerCase().includes(q) || + u.role.toLowerCase().includes(q) + ); + }); + + return ( +
+ {error && ( +
+ {error} + +
+ )} + + {/* Default Role */} +
+

+ Default Role +

+

+ Role assigned to users who don't have an explicit override. Changes take effect on + next sign-in. +

+
+ {(['admin', 'agent', 'client'] as UserRole[]).map((role) => ( + + ))} +
+
+ + {/* User Overrides */} +
+
+

+ User Role Overrides +

+ +
+ + {/* Add user form */} + {showAddUser && ( +
+
+ setNewUserEmail(e.target.value)} + className="input text-sm" + /> + setNewUserName(e.target.value)} + className="input text-sm" + /> + + +
+
+ )} + + {/* Search */} + {config.users.length > 3 && ( +
+ + setSearchQuery(e.target.value)} + className="input w-full pl-8 text-sm" + /> +
+ )} + + {/* User table */} + {filteredUsers.length === 0 ? ( +

+ {config.users.length === 0 + ? 'No user overrides. All users use the default role.' + : 'No matching users.'} +

+ ) : ( +
+ + + + + + + + + + + {filteredUsers.map((user) => ( + + + + + + + ))} + +
+ User + + Role + + Updated + + Actions +
+
+ + {user.displayName || user.email} + + {user.displayName && ( + + {user.email} + + )} +
+
+ + + {user.updatedAt ? new Date(user.updatedAt).toLocaleDateString() : '-'} + + +
+
+ )} +
+ + {/* Role Definitions */} +
+

+ Role Definitions +

+
+ {config.roles.map((role) => ( +
+
+ + + {role.label} + +
+

+ {role.description} +

+
+ {role.permissions.map((perm) => ( + + {perm} + + ))} +
+
+ ))} +
+
+ + {/* Audit Log */} +
+ + + {showAuditLog && ( +
+ {auditLog.length === 0 ? ( +

+ No audit entries yet. +

+ ) : ( +
+ {auditLog.map((entry, i) => ( +
+ +
+ {entry.details} +
+ by {entry.performedByEmail} ·{' '} + {new Date(entry.timestamp).toLocaleString()} +
+
+
+ ))} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/common/AccessDenied.tsx b/src/components/common/AccessDenied.tsx new file mode 100644 index 00000000..a65b5f4e --- /dev/null +++ b/src/components/common/AccessDenied.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { ShieldX } from 'lucide-react'; +import Link from 'next/link'; + +interface AccessDeniedProps { + message?: string; +} + +export default function AccessDenied({ + message = 'You do not have permission to access this page.', +}: AccessDeniedProps) { + return ( +
+ +

+ Access Denied +

+

+ {message} +

+ + Go to Home + +
+ ); +} diff --git a/src/components/common/PermissionGate.tsx b/src/components/common/PermissionGate.tsx new file mode 100644 index 00000000..7911e5e7 --- /dev/null +++ b/src/components/common/PermissionGate.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { usePermissions } from '@/components/providers/PermissionProvider'; +import type { Permission } from '@/types'; + +interface PermissionGateProps { + permission?: Permission; + anyPermission?: Permission[]; + fallback?: React.ReactNode; + children: React.ReactNode; +} + +export default function PermissionGate({ + permission, + anyPermission, + fallback = null, + children, +}: PermissionGateProps) { + const { hasPermission, hasAnyPermission } = usePermissions(); + + if (permission && !hasPermission(permission)) { + return <>{fallback}; + } + + if (anyPermission && !hasAnyPermission(anyPermission)) { + return <>{fallback}; + } + + return <>{children}; +} diff --git a/src/components/common/index.ts b/src/components/common/index.ts index 989a36b8..508c4cbe 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -5,3 +5,5 @@ export { default as LoadingSpinner } from './LoadingSpinner'; export { default as AzureDevOpsIcon } from './AzureDevOpsIcon'; export { default as ProjectList } from './ProjectList'; export { default as FileIcon } from './FileIcon'; +export { default as PermissionGate } from './PermissionGate'; +export { default as AccessDenied } from './AccessDenied'; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 03bfc0f7..bc2cc71b 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -22,6 +22,8 @@ import { X, } from 'lucide-react'; import ZapDeskIcon from '@/components/common/ZapDeskIcon'; +import { usePermissions } from '@/components/providers/PermissionProvider'; +import type { Permission } from '@/types'; interface ViewItem { id: string; @@ -31,30 +33,59 @@ interface ViewItem { count?: number; } -const mainNavItems = [ +interface NavItem { + id: string; + name: string; + icon: React.ReactNode; + href: string; + permission?: Permission; +} + +const allNavItems: NavItem[] = [ { id: 'home', name: 'Home', icon: , href: '/' }, { id: 'tickets', name: 'Tickets', icon: , href: '/tickets' }, - { id: 'users', name: 'Users', icon: , href: '/users' }, + { + id: 'users', + name: 'Users', + icon: , + href: '/users', + permission: 'users:view', + }, { id: 'projects', name: 'Projects', icon: , href: '/projects', + permission: 'projects:view', + }, + { + id: 'team', + name: 'Team', + icon: , + href: '/team', + permission: 'team:view', }, - { id: 'team', name: 'Team', icon: , href: '/team' }, { id: 'live-dashboard', name: 'Live Dashboard', icon: , href: '/reporting', + permission: 'reporting:view', }, { id: 'monthly-checkpoint', name: 'Monthly Checkpoint', icon: , href: '/monthly-checkpoint', + permission: 'reporting:monthly_checkpoint', + }, + { + id: 'admin', + name: 'Admin', + icon: , + href: '/admin', + permission: 'admin:access', }, - { id: 'admin', name: 'Admin', icon: , href: '/admin' }, ]; interface SidebarProps { @@ -80,6 +111,12 @@ export default function Sidebar({ }: SidebarProps) { const pathname = usePathname(); const searchParams = useSearchParams(); + const { hasPermission, isClient } = usePermissions(); + + // Filter navigation items based on user permissions + const mainNavItems = allNavItems.filter( + (item) => !item.permission || hasPermission(item.permission) + ); const counts = ticketCounts || { yourActive: 0, @@ -202,8 +239,11 @@ export default function Sidebar({ })} - {/* Views section */} -
+ {/* Views section - hidden for clients */} +
boolean; + hasAnyPermission: (permissions: Permission[]) => boolean; + isAdmin: boolean; + isAgent: boolean; + isClient: boolean; + isLoading: boolean; +} + +const PermissionContext = createContext(undefined); + +interface Props { + children: React.ReactNode; +} + +export default function PermissionProvider({ children }: Props) { + const { data: session, status } = useSession(); + + const value = useMemo(() => { + const role = (session?.role as UserRole) ?? 'agent'; + const permissions = (session?.permissions as Permission[]) ?? []; + + return { + role, + permissions, + hasPermission: (permission: Permission) => permissions.includes(permission), + hasAnyPermission: (perms: Permission[]) => perms.some((p) => permissions.includes(p)), + isAdmin: role === 'admin', + isAgent: role === 'agent', + isClient: role === 'client', + isLoading: status === 'loading', + }; + }, [session?.role, session?.permissions, status]); + + return {children}; +} + +export function usePermissions() { + const context = useContext(PermissionContext); + if (context === undefined) { + throw new Error('usePermissions must be used within a PermissionProvider'); + } + return context; +} diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts new file mode 100644 index 00000000..e47fecc3 --- /dev/null +++ b/src/lib/api-auth.ts @@ -0,0 +1,71 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { resolveUserPermissions, hasPermission, hasAnyPermission } from '@/lib/permissions'; +import type { Permission, SessionPermissions } from '@/types'; +import type { Session } from 'next-auth'; + +export interface AuthResult { + session: Session; + permissions: SessionPermissions; +} + +/** + * Require an authenticated session. Returns session + permissions or a 401 response. + */ +export async function requireAuth(): Promise { + const session = await getServerSession(authOptions); + + if (!session?.accessToken || !session.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const permissions = resolveUserPermissions(session.user.email, session.user.id); + + return { session, permissions }; +} + +/** + * Require a specific permission. Returns session + permissions or an error response. + */ +export async function requirePermission( + permission: Permission +): Promise { + const result = await requireAuth(); + if (result instanceof NextResponse) return result; + + if (!hasPermission(result.permissions, permission)) { + return NextResponse.json( + { error: 'Forbidden', requiredPermission: permission }, + { status: 403 } + ); + } + + return result; +} + +/** + * Require any of the listed permissions. Returns session + permissions or an error response. + */ +export async function requireAnyPermission( + permissions: Permission[] +): Promise { + const result = await requireAuth(); + if (result instanceof NextResponse) return result; + + if (!hasAnyPermission(result.permissions, permissions)) { + return NextResponse.json( + { error: 'Forbidden', requiredPermissions: permissions }, + { status: 403 } + ); + } + + return result; +} + +/** + * Type guard to check if the result is an AuthResult (not an error response). + */ +export function isAuthed(result: AuthResult | NextResponse): result is AuthResult { + return !(result instanceof NextResponse); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4a49366e..de9512f0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,6 +2,8 @@ import type { NextAuthOptions } from 'next-auth'; import type { JWT } from 'next-auth/jwt'; import AzureADProvider from 'next-auth/providers/azure-ad'; +import { resolveUserPermissions } from '@/lib/permissions'; +import type { UserRole, Permission } from '@/types'; const isDev = process.env.NODE_ENV === 'development'; @@ -36,6 +38,8 @@ declare module 'next-auth' { accessToken?: string; refreshToken?: string; error?: string; + role?: UserRole; + permissions?: Permission[]; user: { id: string; name?: string | null; @@ -53,6 +57,8 @@ declare module 'next-auth/jwt' { error?: string; id?: string; picture?: string; + role?: UserRole; + permissions?: Permission[]; } } @@ -87,6 +93,10 @@ export const authOptions: NextAuthOptions = { try { // Initial sign in if (account && user) { + // Resolve permissions from config file + const userEmail = user.email ?? ''; + const resolved = resolveUserPermissions(userEmail, user.id); + return { ...token, accessToken: account.access_token, @@ -94,6 +104,8 @@ export const authOptions: NextAuthOptions = { accessTokenExpires: account.expires_at ? account.expires_at * 1000 : undefined, id: user.id, picture: user.image ?? undefined, + role: resolved.role, + permissions: resolved.permissions, } as JWT; } @@ -113,6 +125,8 @@ export const authOptions: NextAuthOptions = { session.accessToken = token.accessToken; session.refreshToken = token.refreshToken; session.error = token.error; + session.role = token.role; + session.permissions = token.permissions; if (token.id) { session.user.id = token.id; } diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 00000000..6744631d --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,193 @@ +import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import type { + PermissionsConfig, + UserPermissionOverride, + Permission, + UserRole, + RoleDefinition, + PermissionAuditEntry, + SessionPermissions, +} from '@/types'; + +const DATA_DIR = join(process.cwd(), 'data'); +const CONFIG_PATH = join(DATA_DIR, 'permissions.json'); +const AUDIT_LOG_PATH = join(DATA_DIR, 'permissions-audit.log'); + +// Default role definitions +const DEFAULT_ROLES: RoleDefinition[] = [ + { + name: 'admin', + label: 'Admin', + description: 'Full access to all features including role management', + permissions: [ + 'admin:access', + 'admin:manage_roles', + 'tickets:view_all', + 'tickets:view_own', + 'tickets:create', + 'tickets:edit', + 'tickets:assign', + 'tickets:change_status', + 'tickets:create_internal_notes', + 'team:view', + 'users:view', + 'projects:view', + 'reporting:view', + 'reporting:monthly_checkpoint', + ], + }, + { + name: 'agent', + label: 'Agent', + description: 'Access to all tickets, team, reporting, and internal notes', + permissions: [ + 'tickets:view_all', + 'tickets:view_own', + 'tickets:create', + 'tickets:edit', + 'tickets:assign', + 'tickets:change_status', + 'tickets:create_internal_notes', + 'team:view', + 'users:view', + 'projects:view', + 'reporting:view', + 'reporting:monthly_checkpoint', + ], + }, + { + name: 'client', + label: 'Client', + description: 'View and create own tickets only', + permissions: ['tickets:view_own', 'tickets:create'], + }, +]; + +const DEFAULT_CONFIG: PermissionsConfig = { + defaultRole: 'agent', + roles: DEFAULT_ROLES, + users: [], +}; + +function ensureDataDir(): void { + if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); + } +} + +export function readPermissionsConfig(): PermissionsConfig { + ensureDataDir(); + if (!existsSync(CONFIG_PATH)) { + writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf-8'); + return { ...DEFAULT_CONFIG }; + } + try { + const raw = readFileSync(CONFIG_PATH, 'utf-8'); + return JSON.parse(raw) as PermissionsConfig; + } catch { + return { ...DEFAULT_CONFIG }; + } +} + +export function writePermissionsConfig(config: PermissionsConfig): void { + ensureDataDir(); + writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8'); +} + +/** + * Resolve a user's effective role and permissions. + * Checks for user-specific overrides, otherwise falls back to defaultRole. + */ +export function resolveUserPermissions(email: string, userId?: string): SessionPermissions { + const config = readPermissionsConfig(); + + // Find user override by email (case-insensitive) or userId + const override = config.users.find( + (u) => u.email.toLowerCase() === email.toLowerCase() || (userId && u.userId === userId) + ); + + const role: UserRole = override?.role ?? config.defaultRole; + + // Get base permissions from role definition + const roleDef = config.roles.find((r) => r.name === role); + const basePermissions = roleDef?.permissions ?? []; + + // Apply overrides + let permissions = [...basePermissions]; + + if (override?.permissions) { + // Add extra permissions + for (const p of override.permissions) { + if (!permissions.includes(p)) { + permissions.push(p); + } + } + } + + if (override?.revokedPermissions) { + // Remove revoked permissions + permissions = permissions.filter((p) => !override.revokedPermissions!.includes(p)); + } + + return { role, permissions }; +} + +export function hasPermission( + sessionPermissions: SessionPermissions, + permission: Permission +): boolean { + return sessionPermissions.permissions.includes(permission); +} + +export function hasAnyPermission( + sessionPermissions: SessionPermissions, + permissions: Permission[] +): boolean { + return permissions.some((p) => sessionPermissions.permissions.includes(p)); +} + +// ===== User Override Management ===== + +export function setUserOverride(override: UserPermissionOverride): void { + const config = readPermissionsConfig(); + const idx = config.users.findIndex((u) => u.email.toLowerCase() === override.email.toLowerCase()); + if (idx >= 0) { + config.users[idx] = override; + } else { + config.users.push(override); + } + writePermissionsConfig(config); +} + +export function removeUserOverride(email: string): boolean { + const config = readPermissionsConfig(); + const idx = config.users.findIndex((u) => u.email.toLowerCase() === email.toLowerCase()); + if (idx >= 0) { + config.users.splice(idx, 1); + writePermissionsConfig(config); + return true; + } + return false; +} + +// ===== Audit Logging ===== + +export function appendAuditLog(entry: PermissionAuditEntry): void { + ensureDataDir(); + const line = JSON.stringify(entry) + '\n'; + appendFileSync(AUDIT_LOG_PATH, line, 'utf-8'); +} + +export function readAuditLog(limit = 100): PermissionAuditEntry[] { + if (!existsSync(AUDIT_LOG_PATH)) return []; + try { + const raw = readFileSync(AUDIT_LOG_PATH, 'utf-8'); + const lines = raw.trim().split('\n').filter(Boolean); + const entries = lines.map((line) => JSON.parse(line) as PermissionAuditEntry); + // Return most recent first, limited + return entries.reverse().slice(0, limit); + } catch { + return []; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 531b9595..f83291d3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -426,6 +426,65 @@ export interface WorkItem { priority?: TicketPriority; } +// ===== Permissions & RBAC Types ===== + +export type UserRole = 'admin' | 'agent' | 'client'; + +export type Permission = + | 'admin:access' + | 'admin:manage_roles' + | 'tickets:view_all' + | 'tickets:view_own' + | 'tickets:create' + | 'tickets:edit' + | 'tickets:assign' + | 'tickets:change_status' + | 'tickets:create_internal_notes' + | 'team:view' + | 'users:view' + | 'projects:view' + | 'reporting:view' + | 'reporting:monthly_checkpoint'; + +export interface RoleDefinition { + name: UserRole; + label: string; + description: string; + permissions: Permission[]; +} + +export interface UserPermissionOverride { + userId: string; + email: string; + displayName?: string; + role: UserRole; + permissions?: Permission[]; // Additional permissions beyond role defaults + revokedPermissions?: Permission[]; // Permissions removed from role defaults + updatedAt: string; + updatedBy: string; +} + +export interface PermissionsConfig { + defaultRole: UserRole; + roles: RoleDefinition[]; + users: UserPermissionOverride[]; +} + +export interface PermissionAuditEntry { + timestamp: string; + action: 'role_changed' | 'permission_added' | 'permission_revoked' | 'config_updated'; + targetUserId: string; + targetEmail: string; + performedBy: string; + performedByEmail: string; + details: string; +} + +export interface SessionPermissions { + role: UserRole; + permissions: Permission[]; +} + // Treemap data structure for visualization export interface TreemapNode { name: string;